graphmark 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. {graphmark-0.1.0 → graphmark-0.2.0}/.gitignore +4 -0
  2. graphmark-0.2.0/PKG-INFO +112 -0
  3. graphmark-0.2.0/README.md +86 -0
  4. graphmark-0.2.0/docs/ROADMAP.md +111 -0
  5. {graphmark-0.1.0 → graphmark-0.2.0}/pyproject.toml +24 -1
  6. graphmark-0.2.0/src/graphmark/__init__.py +8 -0
  7. {graphmark-0.1.0 → graphmark-0.2.0}/src/graphmark/cli.py +16 -4
  8. {graphmark-0.1.0 → graphmark-0.2.0}/src/graphmark/config.py +13 -8
  9. {graphmark-0.1.0 → graphmark-0.2.0}/src/graphmark/dismiss.py +10 -1
  10. {graphmark-0.1.0 → graphmark-0.2.0}/src/graphmark/export.py +11 -2
  11. {graphmark-0.1.0 → graphmark-0.2.0}/src/graphmark/graph.py +18 -1
  12. {graphmark-0.1.0 → graphmark-0.2.0}/src/graphmark/interfaces.py +11 -0
  13. {graphmark-0.1.0 → graphmark-0.2.0}/src/graphmark/metrics.py +32 -5
  14. graphmark-0.2.0/src/graphmark/model.py +20 -0
  15. {graphmark-0.1.0 → graphmark-0.2.0}/src/graphmark/parse.py +27 -4
  16. graphmark-0.2.0/tests/fixtures/gaps/expected_oog.json +7 -0
  17. graphmark-0.2.0/tests/fixtures/gaps/similar_oog.json +4 -0
  18. {graphmark-0.1.0 → graphmark-0.2.0}/tests/test_cli.py +32 -0
  19. {graphmark-0.1.0 → graphmark-0.2.0}/tests/test_config.py +21 -2
  20. {graphmark-0.1.0 → graphmark-0.2.0}/tests/test_dismiss.py +16 -0
  21. {graphmark-0.1.0 → graphmark-0.2.0}/tests/test_export.py +19 -0
  22. {graphmark-0.1.0 → graphmark-0.2.0}/tests/test_gaps.py +9 -0
  23. graphmark-0.2.0/tests/test_gaps_banding.py +29 -0
  24. graphmark-0.2.0/tests/test_gaps_oog.py +41 -0
  25. {graphmark-0.1.0 → graphmark-0.2.0}/tests/test_graph.py +30 -0
  26. {graphmark-0.1.0 → graphmark-0.2.0}/tests/test_metrics.py +17 -0
  27. graphmark-0.2.0/tests/test_model.py +22 -0
  28. {graphmark-0.1.0 → graphmark-0.2.0}/tests/test_parse.py +28 -0
  29. {graphmark-0.1.0 → graphmark-0.2.0}/tests/test_smoke.py +4 -1
  30. graphmark-0.1.0/PKG-INFO +0 -57
  31. graphmark-0.1.0/README.md +0 -31
  32. graphmark-0.1.0/docs/ROADMAP.md +0 -73
  33. graphmark-0.1.0/src/graphmark/__init__.py +0 -3
  34. graphmark-0.1.0/src/graphmark/model.py +0 -44
  35. {graphmark-0.1.0 → graphmark-0.2.0}/LICENSE +0 -0
  36. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/README.md +0 -0
  37. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/alt/config.toml +0 -0
  38. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/alt/expected.json +0 -0
  39. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  40. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  41. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
  42. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  43. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  44. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  45. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  46. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  47. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  48. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  49. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/dismiss/expected.json +0 -0
  50. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  51. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  52. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
  53. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  54. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/gaps/config.toml +0 -0
  55. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/gaps/expected.json +0 -0
  56. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/gaps/similar.json +0 -0
  57. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  58. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  59. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  60. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  61. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  62. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  63. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/scoped/config.toml +0 -0
  64. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/scoped/expected.json +0 -0
  65. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  66. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  67. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  68. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  69. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/selflink/config.toml +0 -0
  70. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/selflink/expected.json +0 -0
  71. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/selflink/vault/a.md +0 -0
  72. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/selflink/vault/b.md +0 -0
  73. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/simple/config.toml +0 -0
  74. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/simple/expected.json +0 -0
  75. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  76. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  77. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  78. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  79. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
  80. {graphmark-0.1.0 → graphmark-0.2.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  81. {graphmark-0.1.0 → graphmark-0.2.0}/tests/test_pagerank.py +0 -0
  82. {graphmark-0.1.0 → graphmark-0.2.0}/tests/test_scoped.py +0 -0
  83. {graphmark-0.1.0 → graphmark-0.2.0}/tests/test_selflink.py +0 -0
@@ -8,3 +8,7 @@ dist/
8
8
  .venv/
9
9
  venv/
10
10
  .DS_Store
11
+
12
+ # afk build artifacts (keep config.toml, ignore the noise)
13
+ .afk/transcripts/
14
+ .afk/worktrees/
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: graphmark
3
+ Version: 0.2.0
4
+ Summary: Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults.
5
+ Project-URL: Homepage, https://github.com/cdcoonce/graphmark
6
+ Project-URL: Repository, https://github.com/cdcoonce/graphmark
7
+ Project-URL: Issues, https://github.com/cdcoonce/graphmark/issues
8
+ Author-email: Charles Coonce <charlescoonce@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: graph,knowledge-graph,markdown,obsidian,pagerank,wikilink
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
19
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: networkx>=3.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.6; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # graphmark
28
+
29
+ Deterministic knowledge-graph analysis for markdown / `[[wikilink]]` vaults — orphans, hubs,
30
+ clusters, bridges, siloed notes, neighborhoods, PageRank, and link-gap suggestions over your notes,
31
+ driven by a small config so it works on any Obsidian-family vault.
32
+
33
+ > Status: **v0.1.1 on PyPI.** The engine is complete and pinned by a frozen differential oracle —
34
+ > structural outputs reproduce a proven reference implementation exactly (shape, ordering,
35
+ > tie-breaking). See `CLAUDE.md` for the architecture and the reference-parity contract.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install graphmark # or: uv pip install graphmark
41
+ ```
42
+
43
+ Dev setup:
44
+
45
+ ```bash
46
+ uv pip install -e ".[dev]"
47
+ ```
48
+
49
+ ## Test
50
+
51
+ ```bash
52
+ uv run --extra dev ruff check . && uv run --extra dev ruff format --check . && uv run --extra dev pytest -q
53
+ ```
54
+
55
+ ## CLI
56
+
57
+ Every command takes `--config PATH` (TOML) and/or `--root PATH` (vault root; overrides the config's
58
+ root). Output is deterministic JSON (DOT for `export dot`).
59
+
60
+ ```bash
61
+ graphmark stats --root /path/to/vault
62
+ graphmark orphans --config configs/my-brain.toml
63
+ graphmark hubs --n 10 --root /path/to/vault
64
+ graphmark clusters --root /path/to/vault
65
+ graphmark bridges --root /path/to/vault
66
+ graphmark siloed --root /path/to/vault
67
+ graphmark neighborhood --note a/b.md --depth 2 --root /path/to/vault
68
+ graphmark pagerank --n 10 --alpha 0.85 --root /path/to/vault
69
+ graphmark export dot --root /path/to/vault > graph.dot
70
+ graphmark gaps --root /path/to/vault
71
+ ```
72
+
73
+ Note: `gaps` is a **library-first** metric — it ranks and filters link-gap candidates over a
74
+ similarity function you inject (`metrics.gaps(graph, similar_fn, ...)`); the package itself ships no
75
+ embeddings. The CLI subcommand has no similarity source to inject, so `graphmark gaps` prints
76
+ guidance to stderr and exits 2 rather than silently returning `[]`; use the library API (below).
77
+
78
+ ## Library
79
+
80
+ ```python
81
+ from pathlib import Path
82
+
83
+ from graphmark.config import VaultConfig
84
+ from graphmark.graph import NormalizeResolver, VaultGraph
85
+ from graphmark.metrics import gaps, hubs, stats
86
+ from graphmark.parse import WikilinkExtractor
87
+
88
+ config = VaultConfig(root=Path("/path/to/vault"))
89
+ graph = VaultGraph.build(config, WikilinkExtractor(), NormalizeResolver())
90
+
91
+ print(stats(graph))
92
+ print(hubs(graph, n=10))
93
+
94
+ # gaps: you supply similarity — graphmark owns the deterministic ranking/filtering.
95
+ print(gaps(graph, similar_fn=my_similarity, threshold=0.6, k=8))
96
+ ```
97
+
98
+ `dismiss.py` provides a content-hash dismissal store for gap suggestions: a dismissed pair stays
99
+ suppressed only while both notes exist with unchanged content.
100
+
101
+ ## Development & releases
102
+
103
+ Two long-lived branches: **`dev`** is the integration branch (open PRs against it); **`main`** is
104
+ the release branch. Both are gated by CI (`ruff check` + `ruff format --check` + `pytest -q` on
105
+ Ubuntu and macOS).
106
+
107
+ Releases are automated by conventional commits. When `dev` is promoted to `main`,
108
+ `.github/workflows/release.yml` runs [python-semantic-release](https://python-semantic-release.readthedocs.io):
109
+ it reads the commit history since the last tag, bumps the version, updates `CHANGELOG.md`, tags
110
+ `v<version>`, cuts a GitHub release, and publishes to PyPI via OIDC Trusted Publishing. So the
111
+ version is a function of your commit messages — `feat:` bumps the minor, `fix:`/`perf:` the patch
112
+ (pre-1.0, breaking changes bump the minor, not the major). No manual version edits, no tokens.
@@ -0,0 +1,86 @@
1
+ # graphmark
2
+
3
+ Deterministic knowledge-graph analysis for markdown / `[[wikilink]]` vaults — orphans, hubs,
4
+ clusters, bridges, siloed notes, neighborhoods, PageRank, and link-gap suggestions over your notes,
5
+ driven by a small config so it works on any Obsidian-family vault.
6
+
7
+ > Status: **v0.1.1 on PyPI.** The engine is complete and pinned by a frozen differential oracle —
8
+ > structural outputs reproduce a proven reference implementation exactly (shape, ordering,
9
+ > tie-breaking). See `CLAUDE.md` for the architecture and the reference-parity contract.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install graphmark # or: uv pip install graphmark
15
+ ```
16
+
17
+ Dev setup:
18
+
19
+ ```bash
20
+ uv pip install -e ".[dev]"
21
+ ```
22
+
23
+ ## Test
24
+
25
+ ```bash
26
+ uv run --extra dev ruff check . && uv run --extra dev ruff format --check . && uv run --extra dev pytest -q
27
+ ```
28
+
29
+ ## CLI
30
+
31
+ Every command takes `--config PATH` (TOML) and/or `--root PATH` (vault root; overrides the config's
32
+ root). Output is deterministic JSON (DOT for `export dot`).
33
+
34
+ ```bash
35
+ graphmark stats --root /path/to/vault
36
+ graphmark orphans --config configs/my-brain.toml
37
+ graphmark hubs --n 10 --root /path/to/vault
38
+ graphmark clusters --root /path/to/vault
39
+ graphmark bridges --root /path/to/vault
40
+ graphmark siloed --root /path/to/vault
41
+ graphmark neighborhood --note a/b.md --depth 2 --root /path/to/vault
42
+ graphmark pagerank --n 10 --alpha 0.85 --root /path/to/vault
43
+ graphmark export dot --root /path/to/vault > graph.dot
44
+ graphmark gaps --root /path/to/vault
45
+ ```
46
+
47
+ Note: `gaps` is a **library-first** metric — it ranks and filters link-gap candidates over a
48
+ similarity function you inject (`metrics.gaps(graph, similar_fn, ...)`); the package itself ships no
49
+ embeddings. The CLI subcommand has no similarity source to inject, so `graphmark gaps` prints
50
+ guidance to stderr and exits 2 rather than silently returning `[]`; use the library API (below).
51
+
52
+ ## Library
53
+
54
+ ```python
55
+ from pathlib import Path
56
+
57
+ from graphmark.config import VaultConfig
58
+ from graphmark.graph import NormalizeResolver, VaultGraph
59
+ from graphmark.metrics import gaps, hubs, stats
60
+ from graphmark.parse import WikilinkExtractor
61
+
62
+ config = VaultConfig(root=Path("/path/to/vault"))
63
+ graph = VaultGraph.build(config, WikilinkExtractor(), NormalizeResolver())
64
+
65
+ print(stats(graph))
66
+ print(hubs(graph, n=10))
67
+
68
+ # gaps: you supply similarity — graphmark owns the deterministic ranking/filtering.
69
+ print(gaps(graph, similar_fn=my_similarity, threshold=0.6, k=8))
70
+ ```
71
+
72
+ `dismiss.py` provides a content-hash dismissal store for gap suggestions: a dismissed pair stays
73
+ suppressed only while both notes exist with unchanged content.
74
+
75
+ ## Development & releases
76
+
77
+ Two long-lived branches: **`dev`** is the integration branch (open PRs against it); **`main`** is
78
+ the release branch. Both are gated by CI (`ruff check` + `ruff format --check` + `pytest -q` on
79
+ Ubuntu and macOS).
80
+
81
+ Releases are automated by conventional commits. When `dev` is promoted to `main`,
82
+ `.github/workflows/release.yml` runs [python-semantic-release](https://python-semantic-release.readthedocs.io):
83
+ it reads the commit history since the last tag, bumps the version, updates `CHANGELOG.md`, tags
84
+ `v<version>`, cuts a GitHub release, and publishes to PyPI via OIDC Trusted Publishing. So the
85
+ version is a function of your commit messages — `feat:` bumps the minor, `fix:`/`perf:` the patch
86
+ (pre-1.0, breaking changes bump the minor, not the major). No manual version edits, no tokens.
@@ -0,0 +1,111 @@
1
+ # Roadmap — graphmark (for `afk-driver --expand`)
2
+
3
+ > Read **verbatim** by `afk-driver --expand` and injected into the feature-proposing agent's prompt
4
+ > alongside the live code. Write at the altitude of **intent** — name directions and gaps; let the
5
+ > expander (and the code it reads) propose specifics. **Every proposal must state which Track it
6
+ > advances** — that is how a human decides whether a green gate is sufficient.
7
+
8
+ ## Vision
9
+
10
+ A general, deterministic **knowledge-graph analysis library + CLI** for markdown / `[[wikilink]]`
11
+ vaults — orphans, hubs, clusters, bridges, siloed notes, neighborhoods, PageRank, and link-gap
12
+ suggestions — driven by a small config so it works on **any** Obsidian-family vault. Published on
13
+ PyPI; generalized from a proven vault-specific engine (`brain_map.py`).
14
+
15
+ The central fact of this repo: **correctness is pinned by a frozen differential oracle.** Structural
16
+ outputs are the verbatim results of the reference engine, PageRank is checked against networkx, and
17
+ live-vault parity diffing backs the fixtures. The executor cannot regenerate the oracle — so a
18
+ **green gate proves correctness**, and Track A/C work is autonomous-safe.
19
+
20
+ Identity (settled 2026-07-19): graphmark is **personal infrastructure with a public correctness
21
+ story** — the priority consumer is the owner's vault seam (`graph_cli.py`), not external adoption.
22
+
23
+ ## What's shipped (baseline — do not re-propose)
24
+
25
+ - **The full engine, v0.1.1 on PyPI**: `parse.py` (wikilink extraction, code-span skipping),
26
+ `graph.py` (catalog + `NormalizeResolver` + `VaultGraph.build`), `metrics.py` (stats, orphans,
27
+ hubs, clusters, bridges, siloed_notes, neighborhood, pure-python pagerank, gaps),
28
+ `export.py` (JSON/DOT), `cli.py`, `config.py` (`VaultConfig` + `load_config`), and
29
+ `dismiss.py` (content-hash weaklink dismissal store).
30
+ - **Six frozen differential fixtures** — simple, alt, scoped, selflink, gaps (+ out-of-graph),
31
+ dismiss — plus live-vault parity diffs against the reference engine.
32
+ - **`gaps()` with injected similarity** — graphmark owns the deterministic ranking/filtering
33
+ (already-linked / self / threshold / max-score / prefix / dismissed filters; reciprocal dedup;
34
+ novelty-first ordering); the embedding source is injected by the caller. The gate stays
35
+ fastembed-free.
36
+ - **Packaging** — PyPI Trusted Publishing on `v*` tags; networkx-only runtime dependency.
37
+ - **Reintegration** — the-vault's `graph_cli.py` consumes the package and injects the vault's
38
+ similarity function; /connect and /garden run on it.
39
+
40
+ ## Direction
41
+
42
+ ### Track A — Engine parity (CLOSED at v0.1.1 — do not re-propose engine work)
43
+
44
+ ### Track C — Hardening & honest surface (gate-verifiable; autonomous-safe)
45
+
46
+ _Where we are:_ v0.1.1 is correct on its parity paths but carries correctness debt and dead
47
+ surface no consumer exercises.
48
+ _Where we're going:_ **v0.2.0 — every error path tested, every public knob real.**
49
+
50
+ - Version single-sourcing (pyproject / `__init__.__version__` / smoke-test drift).
51
+ - Robustness: non-UTF8 note handling in `parse_document`; JSON-corruption guards in
52
+ `dismiss.load_dismissed` / `active_dismissed_sigs`; `load_config` clear error on missing keys;
53
+ DOT identifier escaping in `to_dot`; defined behavior for `neighborhood` on an unknown note.
54
+ - Cut or wire dead surface: `model.Edge/Graph/Finding` (never consumed — **removed in the 0.2 API
55
+ cut; `Document` is the only model type**); the CLI `gaps` stub
56
+ (null `similar_fn` — always `[]`); the silent no-op config knobs `wikilink_pattern` and
57
+ `orphan_min_chars` (wire them or delete them); make the dismissal-store path injectable instead
58
+ of hardcoding `.claude/data/connect-dismissed.json`.
59
+ - Move the validated gaps banding policy (threshold / max-score / k / hub-degree defaults) from the
60
+ consumer's argparse into the package so any consumer gets the proven band.
61
+ - A `Similarity` Protocol in `interfaces.py` typing the injected `similar_fn`.
62
+
63
+ _The principle:_ each slice is single-purpose with its asserting test **pre-frozen by the
64
+ conductor before dispatch** — the oracle-first discipline that closed the zero-test hole.
65
+
66
+ ### Track D — `graphmark check`: the deterministic CI gate (feature track)
67
+
68
+ The one unserved niche the ecosystem offers: **headless vault-health gating**. Obsidian's official
69
+ CLI requires the desktop app; the dormant Python incumbent has no CLI; link checkers do no graph
70
+ metrics.
71
+
72
+ - `graphmark check --config ...` — thresholds (max orphans, max unresolved links, max siloed
73
+ notes, ...), non-zero exit on breach, byte-stable JSON report for diffing across runs.
74
+ - Config-driven policy block (`[check]` in the TOML) so the gate is declarative.
75
+ - Later: a thin GitHub Action wrapper. The owner's vault dogfoods the gate first.
76
+
77
+ ### Track B — Judgment the oracle can't cover (human-validated)
78
+
79
+ - New fixtures on a **different schema** than my-brain have hand-authored expected values → a human
80
+ confirms them. Public API naming, packaging/release, and documentation voice are Track B.
81
+
82
+ ## Principles every proposal must respect
83
+
84
+ - **The oracle is the spec.** Never edit an `expected.json` to pass a test. Match it; if it looks
85
+ wrong, flag it.
86
+ - **Pre-frozen asserting tests.** New modules/behaviors ship only when the conductor has committed
87
+ the failing test before the slice runs — a green gate must prove the new code, not just tolerate it.
88
+ - **The gaps contract:** graphmark owns deterministic ranking/filtering; similarity is **injected**;
89
+ the package and its gate stay embedding-free (no fastembed/numpy/scipy in deps).
90
+ - **Implement within the seeded interfaces.** Do not redesign `model`/`interfaces`/`config`
91
+ boundaries.
92
+ - **Dependency-light.** Shipped runtime dep is `networkx` only.
93
+ - **Keep the layers separate** — engine (vault-agnostic) / config (the domain seam) / surface
94
+ (JSON, DOT, CLI) / dismissal store. See `CLAUDE.md`.
95
+ - **TDD, tracer-bullet slices.** `afk-sized` = single-purpose, independently testable; larger is
96
+ `needs-decomposition`.
97
+
98
+ ## Non-goals (CLOSED as dropped 2026-07-19 — do not propose)
99
+
100
+ Evaluated against the ecosystem and dropped for zero consumer pull; do not re-litigate:
101
+
102
+ - Embeddings / semantic search / any similarity backend **inside** the package.
103
+ - Betweenness centrality, Louvain/Leiden community detection.
104
+ - CSV/GML/Cypher export; incremental/cached builds; any LLM "propose" pass.
105
+ - MCP server or retrieval/search surface.
106
+ - Alternate link-syntax adapters (Logseq, `[](.)` markdown) — the pluggable interfaces exist so
107
+ these _can_ be added on demand; do not build them speculatively.
108
+ - Re-platforming (no swapping networkx; no async/parallel rewrites).
109
+ - Performance work without a benchmark showing pain (the live consumer is a ~340-note vault). The
110
+ known ceilings — O(L·N) path-suffix resolution, O(A·(V+E)) `siloed_notes`, per-metric graph
111
+ rebuilds — are documented, accepted, and not to be optimized speculatively.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphmark"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -50,3 +50,26 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
50
50
 
51
51
  [tool.pytest.ini_options]
52
52
  testpaths = ["tests"]
53
+
54
+ [tool.semantic_release]
55
+ # Deployment is driven by conventional-commit history: when dev is promoted to main, the
56
+ # release workflow (.github/workflows/release.yml) runs python-semantic-release, which reads the
57
+ # commits since the last tag, decides the bump, updates the version below, writes CHANGELOG.md,
58
+ # commits, tags v<version>, and cuts a GitHub release. The same workflow then publishes to PyPI.
59
+ version_toml = ["pyproject.toml:project.version"]
60
+ # The semantic-release action runs in its own container without uv; leave the build to the
61
+ # workflow's own uv-based publish step (release.yml), so this is intentionally empty.
62
+ build_command = ""
63
+ tag_format = "v{version}"
64
+ commit_message = "chore(release): {version} [skip ci]"
65
+ # Pre-1.0: a breaking change bumps the minor, not the major, until the API is declared stable.
66
+ major_on_zero = false
67
+ allow_zero_version = true
68
+
69
+ [tool.semantic_release.branches.main]
70
+ match = "main"
71
+
72
+ [tool.semantic_release.commit_parser_options]
73
+ # feat -> minor, fix/perf -> patch; these types appear in the changelog.
74
+ minor_tags = ["feat"]
75
+ patch_tags = ["fix", "perf"]
@@ -0,0 +1,8 @@
1
+ """graphmark — deterministic knowledge-graph analysis for markdown/wikilink vaults."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("graphmark")
7
+ except PackageNotFoundError: # un-installed source checkout
8
+ __version__ = "0.0.0+unknown"
@@ -12,7 +12,6 @@ from graphmark.graph import NormalizeResolver, VaultGraph
12
12
  from graphmark.metrics import (
13
13
  bridges,
14
14
  clusters,
15
- gaps,
16
15
  hubs,
17
16
  neighborhood,
18
17
  orphans,
@@ -73,6 +72,16 @@ def main() -> None:
73
72
  parser.print_help()
74
73
  sys.exit(1)
75
74
 
75
+ if args.command == "gaps":
76
+ # gaps needs a caller-injected similarity source the CLI can't supply; it is
77
+ # library-only. Signpost the library API rather than silently printing [].
78
+ print(
79
+ "gaps requires an injected similarity source; use the library API "
80
+ "(graphmark.metrics.gaps) — see README",
81
+ file=sys.stderr,
82
+ )
83
+ sys.exit(2)
84
+
76
85
  graph, config = _load(args)
77
86
 
78
87
  if args.command == "stats":
@@ -88,13 +97,16 @@ def main() -> None:
88
97
  elif args.command == "siloed":
89
98
  print(to_json(siloed_notes(graph)))
90
99
  elif args.command == "neighborhood":
91
- print(to_json(neighborhood(graph, args.note, depth=args.depth)))
100
+ try:
101
+ result = neighborhood(graph, args.note, depth=args.depth)
102
+ except ValueError as e:
103
+ print(f"error: {e}", file=sys.stderr)
104
+ sys.exit(2)
105
+ print(to_json(result))
92
106
  elif args.command == "pagerank":
93
107
  print(to_json(pagerank(graph, n=args.n, alpha=args.alpha)))
94
108
  elif args.command == "export" and args.format == "dot":
95
109
  print(to_dot(graph))
96
- elif args.command == "gaps":
97
- print(to_json(gaps(graph, lambda _rel, _k: [])))
98
110
 
99
111
 
100
112
  if __name__ == "__main__":
@@ -1,8 +1,8 @@
1
1
  """Vault configuration — the domain seam that makes the engine general.
2
2
 
3
- ``VaultConfig`` holds every vault-specific policy the engine consults. ``load_config`` (TOML
4
- VaultConfig) is intentionally left unimplemented; wiring config through the engine is afk Issue #3.
5
- Fixture tests may construct ``VaultConfig`` directly.
3
+ ``VaultConfig`` holds every vault-specific policy the engine consults. ``load_config`` reads a
4
+ TOML file into a ``VaultConfig`` (paths resolved relative to the TOML's directory). Fixture tests
5
+ may construct ``VaultConfig`` directly.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -20,16 +20,23 @@ class VaultConfig:
20
20
  scoped_folders: list[str] = field(default_factory=list)
21
21
  excluded_dirs: list[str] = field(default_factory=list)
22
22
  rules_files: list[str] = field(default_factory=lambda: ["CLAUDE.md", "CLAUDE.local.md"])
23
- wikilink_pattern: str = r"\[\[(.+?)\]\]"
24
- orphan_min_chars: int = 300
25
23
  transient_prefixes: tuple[str, ...] = ()
26
24
 
27
25
 
28
26
  def load_config(path: Path) -> VaultConfig:
29
- """Load a VaultConfig from a TOML file."""
27
+ """Load a VaultConfig from a TOML file.
28
+
29
+ ``root`` is the only required key (resolved relative to the TOML's directory). Every other
30
+ key that maps to a ``VaultConfig`` field is optional and falls back to the dataclass default;
31
+ any other key in the TOML is silently ignored. A TOML missing ``root`` raises ``ValueError``
32
+ naming the file and the missing key.
33
+ """
30
34
  with open(path, "rb") as f:
31
35
  data = tomllib.load(f)
32
36
 
37
+ if "root" not in data:
38
+ raise ValueError(f"config {path}: missing required key 'root'")
39
+
33
40
  toml_dir = path.parent
34
41
  root = toml_dir / data["root"]
35
42
 
@@ -38,7 +45,5 @@ def load_config(path: Path) -> VaultConfig:
38
45
  scoped_folders=data.get("scoped_folders", []),
39
46
  excluded_dirs=data.get("excluded_dirs", []),
40
47
  rules_files=data.get("rules_files", ["CLAUDE.md", "CLAUDE.local.md"]),
41
- wikilink_pattern=data.get("wikilink_pattern", r"\[\[(.+?)\]\]"),
42
- orphan_min_chars=data.get("orphan_min_chars", 300),
43
48
  transient_prefixes=tuple(data.get("transient_prefixes", [])),
44
49
  )
@@ -37,10 +37,19 @@ def record_dismissal(root: Path, a: str, b: str, *, path: str = _DEFAULT_PATH) -
37
37
 
38
38
 
39
39
  def load_dismissed(root: Path, *, path: str = _DEFAULT_PATH) -> dict:
40
+ """Load the dismissal store, treating a corrupt or unreadable store as empty.
41
+
42
+ Mirrors ``record_dismissal``'s guard: invalid JSON, an unreadable file, or a non-dict
43
+ payload all mean "no active dismissals", never a crash.
44
+ """
40
45
  dismissed_file = root / path
41
46
  if not dismissed_file.exists():
42
47
  return {}
43
- return json.loads(dismissed_file.read_text())
48
+ try:
49
+ data = json.loads(dismissed_file.read_text())
50
+ except (json.JSONDecodeError, OSError):
51
+ return {}
52
+ return data if isinstance(data, dict) else {}
44
53
 
45
54
 
46
55
  def active_dismissed_sigs(root: Path, *, path: str = _DEFAULT_PATH) -> set[str]:
@@ -12,13 +12,22 @@ def to_json(obj) -> str:
12
12
  return json.dumps(obj)
13
13
 
14
14
 
15
+ def _dot_quote(s: str) -> str:
16
+ """Escape a string for use inside a double-quoted DOT identifier.
17
+
18
+ Backslash must be escaped before the quote so an already-present ``\\`` is not
19
+ double-counted. Paths without special characters are returned unchanged.
20
+ """
21
+ return s.replace("\\", "\\\\").replace('"', '\\"')
22
+
23
+
15
24
  def to_dot(graph: VaultGraph) -> str:
16
25
  """Emit a Graphviz digraph containing every node and directed edge."""
17
26
  lines = ["digraph G {"]
18
27
  for node in sorted(graph.nodes):
19
- lines.append(f' "{node}";')
28
+ lines.append(f' "{_dot_quote(node)}";')
20
29
  for src in sorted(graph.out_links):
21
30
  for dst in sorted(graph.out_links[src]):
22
- lines.append(f' "{src}" -> "{dst}";')
31
+ lines.append(f' "{_dot_quote(src)}" -> "{_dot_quote(dst)}";')
23
32
  lines.append("}")
24
33
  return "\n".join(lines)
@@ -30,6 +30,23 @@ def build_catalog(docs: list[Document]) -> dict[str, list[str]]:
30
30
  class NormalizeResolver:
31
31
  """Resolves wikilink displays via normalized basename, with path-suffix fallback."""
32
32
 
33
+ def __init__(self) -> None:
34
+ # Cache the flattened path list per catalog identity. catalog is invariant for a whole
35
+ # VaultGraph.build(), so folder-style links reuse one flatten instead of rebuilding it
36
+ # on every call. Keyed by id() and single-slot: a new catalog evicts the previous.
37
+ self._flat_cache_id: int | None = None
38
+ self._flat_cache: list[str] | None = None
39
+
40
+ @staticmethod
41
+ def _compute_flat(catalog: dict[str, list[str]]) -> list[str]:
42
+ return [p for paths in catalog.values() for p in paths]
43
+
44
+ def _flatten_paths(self, catalog: dict[str, list[str]]) -> list[str]:
45
+ if self._flat_cache_id != id(catalog) or self._flat_cache is None:
46
+ self._flat_cache = self._compute_flat(catalog)
47
+ self._flat_cache_id = id(catalog)
48
+ return self._flat_cache
49
+
33
50
  def resolve(self, display: str, catalog: dict[str, list[str]]) -> str | None:
34
51
  # Strip alias: "Note|alias" → "Note"
35
52
  display = display.split("|")[0]
@@ -39,7 +56,7 @@ class NormalizeResolver:
39
56
  if "/" in display:
40
57
  # Path-suffix resolution: find unique rel_path ending with "display.md"
41
58
  suffix = display.lower() + ".md"
42
- all_paths = [p for paths in catalog.values() for p in paths]
59
+ all_paths = self._flatten_paths(catalog)
43
60
  matches = [p for p in all_paths if p.lower().endswith(suffix)]
44
61
  return matches[0] if len(matches) == 1 else None
45
62
 
@@ -21,6 +21,17 @@ class LinkExtractor(Protocol):
21
21
  ...
22
22
 
23
23
 
24
+ class Similarity(Protocol):
25
+ """Injected similarity source for ``gaps()`` — the package's similarity seam.
26
+
27
+ graphmark owns the deterministic ranking/filtering; the similarity *source* is supplied by
28
+ the caller. An implementation returns up to ``k`` ``(rel_path, score)`` pairs most similar to
29
+ the note at ``rel_path`` (higher score = more similar). No embeddings ship in this package.
30
+ """
31
+
32
+ def __call__(self, rel_path: str, k: int) -> list[tuple[str, float]]: ...
33
+
34
+
24
35
  class Resolver(Protocol):
25
36
  """Resolves a link display to a target note."""
26
37
 
@@ -10,6 +10,16 @@ import networkx as nx
10
10
 
11
11
  from graphmark.config import VaultConfig
12
12
  from graphmark.graph import VaultGraph
13
+ from graphmark.interfaces import Similarity
14
+
15
+ # Validated gaps banding policy — the band proven in daily /connect + /garden use on the owner's
16
+ # live vault (~340 notes). Ships in-package so any consumer gets the tuned band instead of
17
+ # re-deriving policy; consumers opt in by passing these to gaps(). gaps()'s own signature defaults
18
+ # are intentionally left unchanged (non-breaking).
19
+ GAPS_DEFAULT_THRESHOLD = 0.6
20
+ GAPS_DEFAULT_MAX_SCORE = 0.92
21
+ GAPS_DEFAULT_K = 8
22
+ GAPS_DEFAULT_HUB_DEGREE = 40
13
23
 
14
24
 
15
25
  def _undirected(graph: VaultGraph) -> nx.Graph:
@@ -85,7 +95,13 @@ def siloed_notes(graph: VaultGraph) -> list[str]:
85
95
  for bridge in nx.articulation_points(G):
86
96
  H = G.copy()
87
97
  H.remove_node(bridge)
88
- components = sorted(nx.connected_components(H), key=lambda c: -len(c))
98
+ # Largest component is the mainland; the rest are islands. When components tie for
99
+ # largest, break the tie deterministically by member order (the component whose sorted
100
+ # members are lexicographically smallest is the mainland) rather than relying on
101
+ # nx.connected_components' incidental traversal order. NOTE: this tie-break is
102
+ # graphmark's own defined convention — it is unverified against the reference engine,
103
+ # which no frozen fixture exercises; a future divergence is caught by this rule + test.
104
+ components = sorted(nx.connected_components(H), key=lambda c: (-len(c), sorted(c)))
89
105
  for component in components[1:]:
90
106
  island_nodes.update(component)
91
107
  return sorted(island_nodes)
@@ -133,7 +149,7 @@ def pagerank(graph: VaultGraph, n: int = 10, alpha: float = 0.85) -> list[list]:
133
149
 
134
150
  def gaps(
135
151
  graph: VaultGraph,
136
- similar_fn,
152
+ similar_fn: Similarity,
137
153
  threshold: float = 0.0,
138
154
  k: int = 5,
139
155
  note: str | None = None,
@@ -143,7 +159,12 @@ def gaps(
143
159
  hub_degree: int | None = None,
144
160
  targets: list | None = None,
145
161
  ) -> list[dict]:
146
- """Return link-gap suggestions ranked by novelty (non-hub, cross-folder, score-desc)."""
162
+ """Return link-gap suggestions ranked by novelty (non-hub, cross-folder, score-desc).
163
+
164
+ ``similar_fn`` is the injected similarity source (see ``interfaces.Similarity``): given a
165
+ ``rel_path`` and ``k`` it returns up to ``k`` ``(other_rel_path, score)`` pairs. graphmark
166
+ owns all deterministic ranking/filtering; the similarity source is the caller's.
167
+ """
147
168
  if note is not None:
148
169
  target_list = [note]
149
170
  elif targets is not None:
@@ -154,7 +175,7 @@ def gaps(
154
175
  G = _undirected(graph)
155
176
 
156
177
  def _hub(r: str) -> bool:
157
- return hub_degree is not None and G.degree(r) >= hub_degree
178
+ return hub_degree is not None and r in G and G.degree(r) >= hub_degree
158
179
 
159
180
  dedup_map: dict = {}
160
181
 
@@ -195,7 +216,13 @@ def gaps(
195
216
 
196
217
 
197
218
  def neighborhood(graph: VaultGraph, note: str, depth: int = 1) -> dict:
198
- """Return out/back neighbors (and two_hop when depth>=2) for a note."""
219
+ """Return out/back neighbors (and two_hop when depth>=2) for a note.
220
+
221
+ Raises ``ValueError`` if ``note`` is not a node in the graph, so a typo is distinguishable
222
+ from a genuinely isolated note.
223
+ """
224
+ if note not in graph.nodes:
225
+ raise ValueError(f"unknown note: {note}")
199
226
  out = sorted(graph.out_links.get(note, set()))
200
227
  back = sorted(graph.back_links.get(note, set()))
201
228
  result: dict = {"note": note, "out": out, "back": back}
@@ -0,0 +1,20 @@
1
+ """Core data model for graphmark.
2
+
3
+ ``Document`` is the only model type the engine consumes; the resolved graph is
4
+ ``graph.VaultGraph``. Do not rename ``Document``'s type or fields.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ __all__ = ["Document"]
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Document:
16
+ """A single note in the vault."""
17
+
18
+ rel_path: str # posix rel-path from vault root, e.g. "brain/North Star.md"
19
+ text: str
20
+ frontmatter: dict