Falsification-first research graphs in git.
Status: draft · Date: 2026-07-13 · Validated against: a real 23-node investigation
A prior-art search (108 agents, 25+ primary sources) established that no existing tool combines git-native markdown nodes + falsification-aware typed edges + agent-native read/write. The gap is structural: every candidate buys one axis by foreclosing another.
| system | markdown + git | falsification edges | agent-native surface |
|---|---|---|---|
| Basic Memory | markdown ✓ · git ✗ | ✗ free-form wikilinks | ✓ |
| Graphiti | ✗ (Neo4j) | partial (bi-temporal) | ✓ |
| Micropublications | ✗ (OWL) | ✓✓ | ✗ |
| Nanopublications | ✗ (RDF) | ✓✓ | ✗ |
| DISK | ✗ (OWL) | ✗ revision-typed only | ✗ |
The load-bearing finding. A 2019 survey (Wanyana & Moodley, CEUR Vol-2540)
compared the three existing hypothesis ontologies (LABORS, DISK, HELO) and all
three score "No" on "hypothesis appraisal mechanism and unsuccessful
hypotheses." Grepping DISK's released ontology for
falsif|refut|retract|reject|invalid|supersed|negativ returns zero matches.
The entire research-ontology field, across 20 years, does not model hypothesis death.
This is not an oversight; it is an incentive. Publication rewards recording what worked. A private research graph has no such incentive, which is exactly why it can hold what the literature cannot. Negative results are the asset because nobody stores theirs.
The investigation that motivated this produced seven hypotheses; six died. The surviving value was almost entirely in the deaths:
- Four of seven died on economics, not statistics (a fee schedule; an effect too small to cover costs). No model would have saved them.
- Two "findings" were unintentional cherry-picks caught only by specific gates.
- The reusable asset turned out to be the seven method nodes, the tests themselves, not any strategy.
A system that stores only conclusions would have preserved ~15% of that.
- We do not build a general git host. We host knoten graphs, because who may write and who verified what is part of the graph and no general host can enforce it. Git still provides versioning, branching, blame, diffs and history.
- We do not build a UI first. A static site generator over the graph is a phase-3 nicety, and it can emit its own JSON when it exists.
- We do not invent a vocabulary. Micropublications and nanopublications already standardised most of the predicates we need. We add only what is genuinely missing.
- The core knows nothing about any domain. Not trading, not biology. Domain rules live in the graph's own config, as data.
Two primitives, and only two.
Node = one markdown file. YAML frontmatter (machine) + prose body (human). Edge = a typed link declared in that frontmatter.
The prose is not decoration. ## Why it died and ## What would reopen this are the
fields that make a graph worth revisiting, and they are exactly what the previous
attempt (knowledge_graph.jsonl, still empty) could not hold. A schema that cannot
store an argument will be abandoned, because writing to it feels like paperwork
rather than thinking.
question · source · idea · hypothesis · experiment · finding · retraction · gate
A convention only: the core checks node_types for membership and nothing else. A graph
begins with a question and everything descends from it; a source is where the work came
from, including the author's own intuition; gate stands outside the loop the others form,
the bar a claim must survive rather than a stage it passes through. method is
deliberately unclaimed, reserved for "the approach derived from findings that survived".
open ──► alive ──────► superseded
└───► dead (a better claim replaced it)
└───► retracted (WE WERE WRONG: the most valuable node type)
The engine applies the superseded arrow: a node that supersedes another flips it, and must
first face every gate it survived. The superseder is itself of a compressible type (§6);
every target is alive, or already superseded by that same node. And a superseded node
always has something superseding it: delete the general claim and the graph says so.
Every arrow but that one is walkable via knoten update, which appends and moves the
status but cannot rewrite a claim. Immutability protects what was claimed, never the
status: the status is the lifecycle, and git holds the before and after (§7). Without
it an agent could open a hypothesis and never close it, leaving a settled question open
on every frontier.
retracted is first-class. Three claims in the source session were withdrawn. A
graph that records only conclusions and never corrections lies to you in six months.
The retracted node stays, with its post-mortem attached.
Adopt existing predicates. Coin only what the field genuinely lacks.
| predicate | source | meaning |
|---|---|---|
mp:supports |
Micropublications (purl.org/mp) | evidence supports claim (transitive) |
mp:challenges |
Micropublications | evidence contradicts claim |
npx:retracts |
Nanopublications | this node withdraws that one |
npx:supersedes |
Nanopublications | this node replaces that one |
prov:wasDerivedFrom |
PROV-O | this question arose from that one |
prov:used |
PROV-O | used this dataset / method |
| predicate | inverse | meaning |
|---|---|---|
kn:explains |
kn:explainedBy |
this claim is the best explanation of that observation |
kn:generalises |
kn:generalisedBy |
this claim generalises those instances |
kn:followsFrom |
kn:entails |
this claim follows from that one by argument |
Peirce named three ways a claim gets proposed; Popper named the one way it gets tested.
knoten had rich vocabulary for the testing and one untyped prov:wasDerivedFrom for all
three of the others.
The distinction earns its place because a rule can act on it: require_edge_target
matches on the relation, so with one untyped derivation there is no way to say "a
generalisation must cite three findings" without saying it of every derivation. A
per-edge qualifier ({rel: prov:wasDerivedFrom, mode: induction}) parses today and no
rule key can see it. prov:wasDerivedFrom stays as the untyped form for graphs that do
not care to draw the distinction.
A kind confers no status. A generalisation is a conjecture that repeated observation happened to suggest; it faces the same gates as any other claim and can still die. A vocabulary in which "established by induction" exempted a claim from falsification would contradict the thesis of this tool.
The field has no way to say "this claim survived / was killed by a named
methodological gate." DISK's LineOfInquiry is the closest blueprint and it
cannot express failure (zero falsification terms in its ontology).
| predicate | domain → range | meaning |
|---|---|---|
kn:survivedGate |
claim → gate | claim passed this gate. A status: alive claim carries ≥1 wherever the graph has opted into requiring it (§6); the scaffold ships that rule advisory, not enforced. |
kn:killedByGate |
claim → gate | the gate that killed it. The predicate the entire field is missing. |
kn:blockedBy |
claim → finding | a structural blocker (a fee schedule, a venue, a data licence), not a result, a wall. |
kn:survivedGate + the rule engine is the whole safety mechanism when a graph turns it
on: an unchallenged claim cannot be marked alive. The scaffold ships that rule
commented out; until a graph enables it, knoten frontier lists an uncited alive claim
under unchecked instead of refusing it.
Adapted from arXiv:2606.21024 (Negative Knowledge as Failure-aware Shared Memory, Jun 2026), grounded in the seven real deaths of the source session:
cause |
what it means |
|---|---|
no_signal |
the effect is not there |
cost_hurdle |
real effect, too small to pay its costs |
structural_blocker |
a wall you cannot climb, such as a venue you cannot access |
selection_bias |
a cherry-pick, usually an unintentional one |
weak_baseline |
it only beat a strawman |
underpowered |
too few independent bets, such as any t-stat on n<30 |
crowding_decay |
it was real; it got arbitraged away |
Every dead or retracted node should carry a cause, a ## Why it died, and a
## What would reopen this. The last is non-negotiable: it converts a dead end into a
standing offer, and it is what stops the next agent re-running it.
The core does not enforce this vocabulary and must not: a cause of death is domain
knowledge, and §2 says the core knows no domain. The list is a convention; your graph
enforces the parts it cares about, as data (§6's require_field_one_of). What that buys
is a death you can query rather than merely record:
knoten index --where cause=weak_baseline # we have a stronger baseline now. what reopens?That is a query when the cause is a field and a re-read of every post-mortem when it is prose, the moment a research graph pays for itself.
The core enforces whatever the graph declares. Domain knowledge lives in data, never in
code. LinkML was the original plan and was dropped: it validates shape, and every rule
that matters here is a predicate over a node ("does this claim cite a gate?"). A schema
plus a bespoke predicate layer is strictly more machinery than the predicate layer alone,
which is ~50 lines in validate.py.
# graph.yaml: each rule is a SCAR. Write one only when you have a corpse.
rules:
- id: live-claims-must-cite-their-gates
when_status: alive
when_type: hypothesis, finding
require_edge: kn:survivedGate
message: An unchallenged claim is not a finding, it is a hope.
- id: deaths-must-name-a-cause
when_status: dead, retracted
require_sections: Why it died, What would reopen this
require_field_one_of:
cause: [no_signal, cost_hurdle, structural_blocker, selection_bias,
weak_baseline, underpowered, crowding_decay]
message: A cause of death you cannot filter on is a story, not an index.knoten init writes the first of these two commented out: gates are advisory by
default, and uncommenting it turns knoten frontier's listing into a refusal.
| key | effect |
|---|---|
id |
required. Names the rule in the violation. |
message |
what the human reads when it fires. |
when_status / when_type |
only apply to these statuses / types (comma-separated). |
unless_edge |
<rel>: skip the rule for a node that declares that relation. |
require_edge |
node must declare this relation. |
require_sections |
body must contain these ## headings. |
require_field |
frontmatter must carry this key, with any non-empty value. |
forbid_fields |
this type must NOT carry these frontmatter keys (comma-separated). |
require_result |
results: must carry this key. |
require_result_min |
{key: floor}, a numeric floor on a result. |
require_field_one_of |
{field: [allowed]}, a frontmatter field constrained to a closed set. |
require_edge_target |
an edge of this relation must point at a node of this type/status; min counts distinct targets. |
require_backlink |
something of this type/status must point AT this node (rel is the generated inverse). |
forbid_fields is the only key that says what a type must not be, and it exists because every other key is positive: a graph could demand a hypothesis carry a claim and never stop it also carrying the run and the result.
require_field is the open sibling of require_field_one_of: a url or a doi has no
closed set of legal values, so the only thing worth demanding is that the answer got
written down. knoten new scaffolds such a field EMPTY rather than as TODO, because a
placeholder would satisfy the rule and new + validate would stop being a checklist.
The last two are the only checks that look past the node being checked, and they are what
make a claim's fate outlive the moment it was written: require_edge_target fails
everything derived from a finding the day that finding dies, and require_backlink is the
only way to police work that was abandoned rather than written badly, since a hypothesis
nobody tested declares nothing wrong.
An unknown rule key is a hard error, as is an unknown edge relation
(kn:killdByGate, one letter dropped) and frontmatter that does not parse. A rule the
engine cannot understand would enforce nothing while reporting ✓ all rules pass, and a
validator that silently accepts is worse than no validator.
The graph declares its vocabulary too. node_types takes a list, or a mapping when you
want to write down what the words mean, the only place they can be defined, since the
core refuses to:
node_types:
hypothesis: a falsifiable claim derived from an idea
gate: a standing rule every claim must survive; a bar, not a stage
statuses: [open, alive, dead, retracted, superseded, active]
tags: [decoding, reasoning, prompting, evaluation]The keys are the vocabulary either way; the values are for the reader and for
knoten viz, which labels each column with them. knoten init writes the mapping form.
Declare none and none is checked (§2), but a graph that does declare has said those are
the only legal words, so type: hypthesis is a typo and status: ded is a claim that
would silently vanish from every query.
Always-on structural checks, which no graph declares: dangling-edge,
missing-attachment, unknown-relation, authored-backlink, missing-type,
unknown-type, unknown-status, unknown-tag, malformed-tags, mismatched-id,
malformed-results, malformed-repro, supersession (the bar of §3: a superseder of a
compressible type over alive targets of a compressible type, one shared question, the
union of the gates they survived, a ## Covers section naming each, and no superseded
node whose superseder has been deleted), and not-a-gate (a migration aid, deletable
once graphs written before the gate rename have moved).
Two known properties, documented rather than guarded. Retracting a general node stops
it covering anything, and the targets it retired stay superseded until a person revives
them: only a person can say whether an old specific is worth having back. And nothing caps
how many alive findings a question may hold; knoten frontier's COMPRESSIBLE band is
the whole of the nudge to write the rule above them.
A biology graph declares entirely different rules. The core never changes.
my-graph/
graph.yaml # identity + rules
nodes/*.md # one node per file: the source of truth
attachments/<id>/* # the script that killed it, the plot that shows why
knoten new and knoten commit stamp created:; knoten update stamps updated:.
Both are plain ISO date strings, which the YAML 1.2 loader keeps as strings rather than
coercing to datetime.date. Git knows when the file changed, which is not when the
claim did (a typo fix and a status flip are the same event to git), and reading it
would cost one subprocess per node to order a frontier.
Git provides: history, blame, diff-a-claim-as-it-changed, branches-as-research- directions, PRs-as-peer-review, and hosting. We write none of it.
This section once named a tool-protocol server as the thing that made knoten compound, and meant it. That claim was wrong, and this is the reversal, stated plainly rather than slid past.
The previous attempt failed because the graph was a byproduct of automation: when the orchestrator did not run, nothing was written, while a plain chat session produced 15 experiments whose knowledge would have evaporated without hand-written memory files. The fix was right: make the graph primary, and make writing to it the path of least resistance. The delivery was not.
The measurement. That surface loaded ~2,340 tokens into every session whether the
agent touched the graph or not (1,928 of schema + 412 of instructions), against ~304 for
knoten --help and only when asked. Format compounded it: the same 55 nodes cost ~2,551
tokens as its JSON (46/node) against ~1,185 as columnar prose (21/node), 2.2x. That is
why prose is the CLI's default and --json is opt-in.
So the CLI is the agent surface, and the server has been deleted. ops.py holds one
implementation of every read (index, query, frontier, gates, show, validate, path) as a
function returning a dict, which the CLI renders as prose or dumps with --json; commit
and update are shared the same way. SKILL.md teaches the loop: frontier →
index/query → show → gates → commit/update → attach.
Keeping the server for shell-less clients was defensible and stayed defensible; it was
just outweighed. Every cross-surface drift bug this project recorded came from having two
: update's refusal built twice with different keys, --field coercing 2 to 2.0 on
one side only, ops.index(query=...) reachable in Python with no CLI flag. The shell is
the interface.
What it taught, which outlived it:
- Derive the schema, never write one. Its tools were plain functions: the name was the tool name, the docstring the description, the annotated signature the input schema. A hand-written schema quietly stops matching the code it documents; a derived one cannot.
- State the loop once. Four tools each shouting "CALL THIS FIRST" give an agent the
same ordering signal as none.
SKILL.mdinherited that job. - The gate belongs under every door. The check lives in
commit, not in a transport, which is why deleting the transport removed no enforcement. The candidate node is parsed and checked in memory; nothing reaches the filesystem until it is clean. - An id authored by a model is not a path. It becomes a filename, so it is constrained to kebab-case, a guard the CLI lacked until it was the only entry point left.
"Has this been tried?" and "have we done anything like this?" are different questions, and one mechanism cannot answer both.
knoten query is keyword retrieval: idf-weighted, ranked, capped. It was originally an
AND over tokens, which made the headline question fail on the README's own example:
"has anyone tried self-consistency?" matched nothing, because the node contains no
"has", "anyone" or "tried", and the agent was told the work was untested. A false negative
is the only failure of this system that causes real work to be redone. So: OR with
ranking, the full frontmatter in the haystack (repro.model was unsearchable), and a miss
that says so honestly: "no keyword match, this is NOT proof it is untested."
knoten index answers the second question by not being a search engine. It emits the
whole graph, one line per node (id, verdict, tags, claim) and lets the reader judge
relatedness. The reader is an LLM: a better semantic matcher than anything we could ship,
and free. On a 500-node graph a broad query returned ~83k tokens; the index is ~9k, and
one tag narrows it to ~2.5k. That is what tags: is for: not search, but the filter axis
that keeps the index readable as the graph grows.
Embeddings are deliberately absent. The paraphrase gap they close is real, but the
agent already closes it; sentence-transformers would put torch behind a project whose
pitch is one dependency, and an embedding API would end the offline story. Both readers go
through one function, core.retrieve(), so a knoten[semantic] extra could replace that
body the day a graph outgrows a tag-filtered index, which the 1k-5k node case does not.
| phase | deliverable | status |
|---|---|---|
| 0 | Dogfood: encode a real investigation by hand | ✅ done |
| 1 | knoten CLI (init/new/validate/query/index/frontier/gates/path/show/attach) + rule engine |
✅ done |
| 2 | Tool-protocol server | ✅ done, later demoted to a fallback, then removed (§8) |
| 2.5 | CLI becomes the primary agent surface: ops.py as the one implementation behind every read, --json on every read, commit/update on the CLI, SKILL.md |
✅ done |
| 3 | knoten viz: the graph as one HTML file, cards, map and metric |
✅ done |
| 4 | Remote graphs | ✅ a graph is its own git repository, shared on GitHub (§12) |
Phase 0 validated the schema against real content, including retractions, structural blockers, and prose that no JSON schema could hold.
Basic Memory is AGPL-3.0 (strong copyleft). Its tool design is excellent and worth studying, but vendoring its code would make this AGPL too. Reimplement, do not fork, if this may ever be distributed or hosted.
Micropublications, nanopublications, PROV-O and LinkML are all open and safe to adopt.
- Do we emit RDF? A
graph.ttlexport would make the graph interoperable with the nanopub ecosystem for ~nothing. Probably yes, phase 3. Embeddings forAnswered (§8.1): no, and probably never. The consumer is an LLM, so the whole graph as a one-line-per-node index beats vector similarity at the question that matters ("anything like this?") for zero dependencies. Revisit only when a tag-filtered index stops fitting in a context window.knoten query?- Multi-graph federation: one agent querying trading and biology graphs. Defer until a second graph exists.
A closedAnswered: yes.causevocabulary (§5) as a rule primitive?require_field_one_ofshipped, and a second graph did want it.
A graph is its own git repository, and sharing it is hosting that repository on GitHub
or any forge: collaborators are the repository's collaborators, one branch is the whole
history, git pull rebases so nothing is ever merged, and a new node is a new file so
two people filing nodes never conflict. The gate is the pre-commit hook on every clone
plus a workflow that runs knoten validate on every push.
knoten once carried its own server (knoten serve: git over HTTPS behind a token,
invites, roles, a pre-receive gate, and an identity of signed commits kept in the graph).
It was removed on 2026-09-09. It worked, and nobody would run it: a graph that can only
be shared by hosting a machine behind TLS is a graph that is not shared. What that layer
enforced on the server, the hook and the workflow now report on the clone and on the
commit; who may write is the forge's decision, not the graph's. If a hosted git service
ever runs a custom check before accepting a push, that is where the gate belongs.