The Durability Curve · Walkthroughs · 03
The Grounding Pass
A one-file check that finds the notes resting on nothing you can trace.
Download it and run it on your own machine, against your own notes. It reads them locally and sends nothing anywhere, so they never leave your computer. You get it straight from me rather than a package registry, so you can read every line before you run it. The whole tool is on this page below.
01What it does
An orphan checker asks whether anything links to a note. That is presence, a one-hop question any query answers. The grounding pass asks a harder, multi-hop one a one-hop orphan check never gets to: follow a note's outbound links, and do you ever arrive at a real source?
A note can link out to five others and still reach nothing, because every one of them is derived too. That note is a source-orphan: well-connected, trusted, resting on nothing. It is the kind of note that piles up whenever notes get written faster than their sources get attached, which is exactly what an AI assistant does at scale. No orphan tool can see it.
A note can carry every link and still reach no source. That gap is what this finds.
The check reads every note, walks the whole link graph in a single pass, and prints the source-orphans. It makes no network call and it never touches your notes. It appends one dated count to a log, so you can watch the number fall week on week.
02Run it
Two ways, depending on how you work.
Hand it to an agent
If you use an AI coding assistant that can run commands on your machine, such as Claude Code or Cursor, the simplest path is to let it do the work. Download the file above into the folder you are working in, then paste the prompt below into your agent. It will read the tool, work out how your notes mark sources, set the flags to match, run it, and talk you through what it finds. No terminal or Python setup on your part.
I have a folder of markdown notes and I want to find "source-orphan" notes: notes that link to other notes but never reach a real source, like a saved article, a paper, or a URL. An ordinary orphan check misses these, because they have plenty of links.
I have downloaded a single-file, read-only Python tool for this called grounding_pass.py (it is also at https://durabilitycurve.com/grounding-pass/grounding_pass.py). It only reads notes and never edits them.
Please:
1. Find grounding_pass.py in this folder, or download it from the URL above if it is not here. Read it first and tell me in one sentence what it does.
2. Look at how my notes mark sources: which folders hold saved source material (for example Sources, Clippings, References), and which frontmatter fields hold a link (for example url, source, source_url).
3. Run it on my notes folder, setting the flags to match what you found, for example:
python3 grounding_pass.py "<my notes folder>" --source-folders <folders> --link-fields <fields>
4. Show me the source-orphans, then run it again with --strict and show that list too.
5. Take three of the flagged notes and tell me plainly whether each one should be re-grounded, deleted, or left alone as navigation.
Do not edit any of my notes. This is read-only.
Or run it yourself
If you would rather run it by hand, and you have Python 3.8 or newer, point it at your notes folder:
$ python3 grounding_pass.py /path/to/your/notesOn a small notes folder with sources kept in one place and concept notes linking back to them, a run reads like this:
grounding pass · /vault
7 notes · 2 sources · 4 derived · 1 navigation (excluded) · 0 unreadable
mode: grounded if a source is within 2 hop(s)
SOURCE-ORPHANS: 1 derived note(s) reach no source
- wiki/compaction.md
The two-hop default is forgiving on purpose. It allows the honest chain of a note linking to a literature note that links to the source. Strict mode demands a direct citation instead, and the borrowed grounding falls away:
$ python3 grounding_pass.py /vault --strict
SOURCE-ORPHANS: 2 derived note(s) reach no source
- wiki/compaction.md
- wiki/context-rot.md
The whole check rests on one rule: what counts as a source. Four flags tune it to your setup. --source-folders and --link-fields teach it where your provenance lives, --exclude-folders keeps navigation out of the report, and --max-hops sets how far grounding may travel. Each is documented at the top of the file.
03Read it before you run it
It opens every file in the folder you point it at, so you should see exactly what it does first. Here is the whole thing, standard library only, on the page and nothing hidden. Scroll it, then decide.
#!/usr/bin/env python3
"""grounding_pass.py: find the notes your AI wrote that reach no source.
An orphan checker asks "does anything link TO this note." That is link presence, and it is a
1-hop question any query can answer. This asks a different, multi-hop question: "following this
note's outbound links, do you ever arrive at a SOURCE, a note with real provenance?" A note can
link out to five other notes and still reach no source, because every one of them is itself
derived. That note is a source-orphan: well-connected, trusted, resting on nothing. It is the
exact note an LLM maintaining your vault produces, and no orphan tool can see it.
The check needs no model, makes no network call, and never writes to your notes. It reads them,
walks the link graph, and prints the source-orphans. It appends one line, a date and the counts,
to a local log so you can watch the number fall week on week.
python3 grounding_pass.py /path/to/your/vault
Classification (the one rule everything rests on, tune it to your vault in the flags):
A note is a SOURCE if either
- its frontmatter carries provenance: a non-empty `url`, `source`, or `source_url`; or
- it lives in a folder you name as source material (default: Sources, Clippings).
A bare http link in the body does NOT make a note a source. An agent-written summary cites URLs
all the time; blessing it as a source would let the very note this tool hunts launder itself.
Everything else is DERIVED. Notes that are legitimately sourceless navigation (MOCs, hubs, daily
notes, templates) are excluded from the report by folder, and counted separately.
Grounding is a distance. A note is grounded if a source sits within N outbound hops of it
(default 2, which allows the legitimate note -> literature-note -> source chain). --strict sets
N=1 and demands a direct link to a source; raise --max-hops to be more forgiving.
Two blind spots it will tell you about, and cannot fix:
1. It checks that a source is REACHED, not that the source SUPPORTS the claim. That is a
semantic question and needs a model.
2. A larger N launders grounding through chains of derived notes. N=2 is the honest default;
--strict (N=1) is the aggressive read that trusts nothing but a direct citation.
Zero dependencies. Python 3.8+.
"""
from __future__ import annotations
import argparse
import datetime as _dt
import json
import os
import re
import sys
WIKILINK = re.compile(r"\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]")
# a markdown link to a .md note, allowing an optional #anchor or ?query after .md
MDLINK = re.compile(r"(?<!!)\[[^\]]*\]\(([^)#?]+\.md)(?:[#?][^)]*)?\)")
# a provenance field and its value ON THE SAME LINE. [^\S\n] is horizontal whitespace
# only, so an empty `url:` followed by another key does NOT capture the next line's
# value (which would misclassify an ungrounded note as a source).
FM_KEY = re.compile(r"^(url|source|source_url)[^\S\n]*:[^\S\n]*(\S.*?)?[^\S\n]*$", re.I | re.M)
def split_frontmatter(text: str) -> tuple[str, str]:
"""Return (frontmatter, body). Frontmatter is the leading --- ... --- block, else ''."""
if text.startswith("---"):
end = text.find("\n---", 3)
if end != -1:
nl = text.find("\n", end + 1)
return text[3:end], text[nl + 1:] if nl != -1 else ""
return "", text
def has_provenance(fm: str) -> bool:
for m in FM_KEY.finditer(fm):
val = (m.group(2) or "").strip().strip("'\"")
if val and val.lower() not in ("''", '""', "none", "null", "~", "[]"):
return True
return False
def in_any(path_parts: set[str], folders: list[str]) -> bool:
return any(f in path_parts for f in folders)
def main() -> int:
ap = argparse.ArgumentParser(description="Find derived notes that reach no source.")
ap.add_argument("vault", help="path to your notes folder (a vault of .md files)")
ap.add_argument("--source-folders", nargs="*", default=["Sources", "Clippings"],
help="folders whose notes count as source material")
ap.add_argument("--exclude-folders", nargs="*",
default=["Templates", "templates", "Daily", "Journal", "_system"],
help="folders of legitimately sourceless navigation, kept out of the report")
ap.add_argument("--max-hops", type=int, default=2,
help="a note is grounded if a source is within this many outbound hops (default 2)")
ap.add_argument("--strict", action="store_true",
help="shorthand for --max-hops 1: require a DIRECT link to a source")
ap.add_argument("--link-fields", nargs="*", default=[],
help="frontmatter fields whose wikilink values also count as outbound links "
"(for vaults that cite sources in frontmatter, not the body)")
ap.add_argument("--limit", type=int, default=25, help="how many source-orphans to print")
ap.add_argument("--log", default=None, help="log file (default: <vault>/.grounding-log.jsonl)")
args = ap.parse_args()
vault = os.path.abspath(args.vault)
if not os.path.isdir(vault):
print(f"not a folder: {vault}", file=sys.stderr)
return 2
# --- 1. read every note, classify, and record outbound links -------------
notes: dict[str, dict] = {} # key -> {kind, rel, links:set[str]}
by_base: dict[str, str] = {} # basename (lowercased, no ext) -> key
unreadable = 0
for root, dirs, files in os.walk(vault):
dirs[:] = [d for d in dirs if not d.startswith(".")] # skip .obsidian, .git, caches
for fn in files:
if not fn.endswith(".md"):
continue
full = os.path.join(root, fn)
rel = os.path.relpath(full, vault)
key = rel
base = os.path.splitext(fn)[0].lower()
by_base.setdefault(base, key)
notes[key] = {"rel": rel, "base": base, "kind": None, "links": set(), "full": full}
for key, n in notes.items():
try:
text = open(n["full"], encoding="utf-8").read()
except (UnicodeDecodeError, OSError):
n["kind"] = "unreadable"
unreadable += 1
continue
fm, body = split_frontmatter(text)
parts = set(os.path.dirname(n["rel"]).split(os.sep))
if has_provenance(fm) or in_any(parts, args.source_folders):
n["kind"] = "source"
elif in_any(parts, args.exclude_folders):
n["kind"] = "navigation"
else:
n["kind"] = "derived"
raw = set(m.group(1).strip() for m in WIKILINK.finditer(body))
raw |= set(os.path.splitext(os.path.basename(m.group(1)))[0]
for m in MDLINK.finditer(body))
for fld in args.link_fields:
# the field's inline value, plus any list items indented beneath it
block = re.search(rf"^{re.escape(fld)}\s*:(.*?)(?=^\S|\Z)", fm, re.M | re.S)
if block:
raw |= set(WIKILINK.findall(block.group(1)))
n["links"] = raw
# --- 2. resolve link targets to note keys --------------------------------
def resolve(target: str) -> str | None:
return by_base.get(target.lower()) or by_base.get(os.path.basename(target).lower())
for n in notes.values():
n["out"] = {r for r in (resolve(t) for t in n["links"]) if r}
sources = {k for k, n in notes.items() if n["kind"] == "source"}
max_hops = 1 if args.strict else max(1, args.max_hops)
# --- 3. which notes reach a source within max_hops? ----------------------
# Bounded reverse-graph BFS from every source. A note is grounded if a source
# sits within max_hops outbound hops of it. One pass over the whole graph.
rev: dict[str, list[str]] = {}
for k, n in notes.items():
for t in n.get("out", set()):
rev.setdefault(t, []).append(k)
reaches = set(sources)
frontier = set(sources)
for _hop in range(max_hops):
nxt = set()
for cur in frontier:
for pred in rev.get(cur, ()):
if pred not in reaches:
reaches.add(pred)
nxt.add(pred)
if not nxt:
break
frontier = nxt
derived = [k for k, n in notes.items() if n["kind"] == "derived"]
orphans = sorted(k for k in derived if k not in reaches)
navigation = [k for k, n in notes.items() if n["kind"] == "navigation"]
# --- 4. report -----------------------------------------------------------
total = len(notes)
print(f"\ngrounding pass · {vault}")
print(f" {total} notes · {len(sources)} sources · {len(derived)} derived · "
f"{len(navigation)} navigation (excluded) · {unreadable} unreadable")
print(f" mode: grounded if a source is within {max_hops} hop(s)"
f"{' (strict)' if max_hops == 1 else ''}")
print(f"\n SOURCE-ORPHANS: {len(orphans)} derived note(s) reach no source")
for k in orphans[:args.limit]:
print(f" - {k}")
if len(orphans) > args.limit:
print(f" … and {len(orphans) - args.limit} more")
# --- 5. append the count to a local log so you can watch it fall ----------
log = args.log or os.path.join(vault, ".grounding-log.jsonl")
row = {
"date": _dt.date.today().isoformat(),
"source_orphans": len(orphans),
"derived": len(derived),
"sources": len(sources),
"max_hops": max_hops,
}
try:
with open(log, "a", encoding="utf-8") as fh:
fh.write(json.dumps(row) + "\n")
print(f"\n logged to {log}")
except OSError as e:
print(f"\n could not write log ({e})", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
04It has been verified
Verified, and the proof runs in one command. A companion self-test builds the worked example above, checks the output line by line, and confirms nothing was written.
05What it will not do
The honest limits, so you read the number right.