Knowledge model¶
The foundation of the Knowledge OS epic (#144). A single, read-only, incremental in-memory index that represents the vault as ideas — the substrate every later layer (Lifecycle, Semantic Graph, Discovery, Health) reads from, instead of each feature re-scanning the metadata cache with its own snapshot shape.
Lives in src/architecture/knowledge/, with a strict pure / Obsidian-facing split so every derivation and query is unit-testable in jest's node environment.
src/architecture/knowledge/
model/Idea.ts # pure — Idea/Relation/Claim/Source types, deriveIdea(), safe defaults
model/KnowledgeModel.ts # pure — in-memory graph: Map<path,Idea> + adjacency + edges-by-type
model/schema.ts # pure — StateSchema/RelationSchema/ClaimSchema extension points
parse/inlineFields.ts # pure — standalone `key:: value` parser (no Dataview)
derive/edges.ts # pure — links → directed typed edges
lifecycle/ # pure — lifecycle states + transition machine + StateSchema (#146)
relations/ # pure — semantic relation vocabulary + SemanticRelationSchema (#147)
claims/ # pure — claim/source keys + link/text classifier + ClaimSourceSchema (#148)
query/queries.ts # pure — read-only query surface
snapshot.ts # Obsidian-facing (thin) — TFile + metadata cache → IdeaSnapshot
KnowledgeIndex.ts # Obsidian-facing — getInstance() singleton, event wiring, build state
index.ts # barrel
The idea model¶
Each note is modelled as an Idea, keyed by its vault path:
| Field | Meaning |
|---|---|
path | Identity (decision #3 — by path, re-keyed on rename). |
title, created, modified | Basic metadata (basename fallback if no title). |
state | Lifecycle state. The lifecycle (#146) registers a StateSchema classifying notes into fleeting → … → archived (fallback fleeting); with no schema registered it defaults to DEFAULT_STATE ("unknown"). |
relations | Outgoing typed, directed edges. Vocabulary owned by #147; plain [[links]] become DEFAULT_RELATION_TYPE ("link") edges. |
claims | Claims & sources. Owned by #148; defaults to []. |
maturitySignals | Raw signals only (inDegree, outDegree, degree, hasSources). The maturity score is #158/#159, not here. |
deriveIdea(snapshot, schemas?) turns a pure IdeaSnapshot into an Idea. It never throws and never touches Obsidian: a cache-miss / empty-frontmatter snapshot yields the documented defaults.
Query surface¶
Pure functions over the model (query/queries.ts), all O(edges) reads that never re-derive:
get,byState,statePartitionedgesByType,outgoingRelations,incomingRelationsorphans(no incoming),leaves(no outgoing) — plus the unambiguous primitivesnotesWithNoIncoming/notesWithNoOutgoinghubs(threshold),byMaturityunsourced(claim-aware, #148),claimsWithoutSources,sourcesByReferenceCount
Terminology (spec FR-5)
Here orphan = no incoming and leaf = no outgoing. The slip-box health view (classifyHealth) currently uses the inverted convention. The unambiguous primitives are exposed so the future health-view migration can map either naming explicitly, rather than silently swapping columns.
Incremental-update contract¶
The index exposes status: "idle" | "building" | "ready". Build is synchronous (decision #4): it gathers snapshots via the metadata cache, derives ideas into the KnowledgeModel, and emits one log timing line. Each vault event mutates a single entry — no full rebuild:
| Event | Model mutation |
|---|---|
create / modify | upsert(deriveIdea(snapshot)) (single-entry) |
delete | remove(path) — drops outgoing edges; incoming edges from others tolerate the missing target |
rename(file, oldPath) | rename(oldPath, newPath) — re-keys the entry and rewrites every edge referencing oldPath (the one O(edges) op decision #4 permits) |
sequenceDiagram
participant V as Vault event
participant K as KnowledgeIndex
participant S as snapshot (read-only)
participant M as KnowledgeModel
V->>K: modify(file)
K->>S: gatherSnapshot(file) (metadata cache only)
S-->>K: IdeaSnapshot
K->>M: upsert(deriveIdea(snapshot))
Note over M: single-entry update<br/>adjacency + edges-by-type kept fresh
Events are registered through plugin.registerEvent (auto-removed on unload); the initial build runs on onLayoutReady and re-runs on the first metadata-cache "resolved" so resolvedLinks are complete.
Design decisions¶
- Read-only, rebuilt in memory on load — no cache file, zero writes to the vault.
- Standalone inline-
key::parser — no Dataview dependency ("no lock-in"). - Identity by vault path — re-keyed on rename; a stable-ID mode can layer on later via #122.
- Synchronous build + "building" state — the query surface is O(edges), not O(vault); chunked/async is deferred until profiling requires it.
Extension points (#146 / #147 / #148)¶
Sibling issues register a StateSchema, RelationSchema, or ClaimSchema via KnowledgeIndex.getInstance().registerSchemas({...}). deriveIdea calls the registered parser or falls back to the documented default. The Idea shape and the query signatures stay fixed, so a new vocabulary becomes queryable without changing either.
Semantic relations (#147)¶
SemanticRelationSchema (in the pure src/architecture/knowledge/relations/) turns a note's frontmatter and inline key:: [[target]] fields into typed, directed edges, so [[links]] gain meaning and edgesByType / incomingRelations / outgoingRelations answer questions like "what contradicts this?".
Vocabulary (fixed set; extensible in code, no settings editor yet): supports, contradicts, expands, inspired-by, question, example, implements — plus the plain link fallback for untyped links. A target that gets a semantic type is not also emitted as a link edge, and (type, from, to) duplicates collapse to one.
Hybrid parsing (perf decision — the build stays synchronous and cache-only):
| Source | When | How |
|---|---|---|
Frontmatter (supports: ["[[X]]"]) | inside the synchronous build | free from the metadata cache; targets resolved via getFirstLinkpathDest into the snapshot's additive resolvedTargets |
Inline (supports:: [[X]]) | a deferred pass after layout-ready (KnowledgeIndex.enrichInlineRelations), then incrementally | reads bodies via cachedRead; the full pass happens once per session, after which only changed files are read (#459) |
The deferred inline pass is gated by the parseInlineRelations setting — on by default on desktop, off on mobile (resolved as ?? !Platform.isMobile). It is read-only (zero writes), batched/yielding, and wraps each file in try/catch.
Only what changed (#459)¶
The pass used to read every note in the vault, every time it ran — O(vault content), and the reason it is off by default on mobile. It now compares a cheap fingerprint (mtime + size) against what the last pass saw, and reads only the difference. Measured on a fake vault: the second pass reads one file instead of the whole vault.
The fingerprint is not a hash, because hashing means reading and reading is the cost being avoided. Size is part of it because some sync clients preserve modification times, and an edit that keeps its timestamp must not be invisible. A file that could not be read stays changed, so the next pass tries it again rather than leaving it stale forever. A change of scope or of vocabulary resets everything, because both alter what enrichment would conclude from the same text.
The fingerprints live in memory, for the session only. Nothing is persisted, so the first pass after every launch is still a full one — which is why the mobile default is unchanged. Persisting them is the open question in #460.
Two bugs this fixed¶
- A deleted relation used to survive. The old pass skipped a note whose body had nothing interesting in it, so removing a
supports::line left the relation in the model until the next restart. The pass now always upserts a changed note, with or without inline fields. - Editing a note used to drop its inline relations. A vault
modifyrebuilds the note fromgatherSnapshot, which is cache-only and has no inline fields — so a note'ssupports::relations vanished from the model the moment you typed in it. An edit now schedules a debounced (2 s) re-pass, which the fingerprints make cost exactly one read.
Only the pure schema and vocabulary are Obsidian-free (guarded); link resolution and body reads live in the Obsidian-facing snapshot.ts / KnowledgeIndex.ts. Wikilink aliases and headings/blocks are stripped ([[X|alias]] / [[X#h]] → X); unresolved links are excluded.
Capability note: enabling inline parsing performs read-only body reads via the
VaultcachedReadfacade. No writes, no network.
Claims & sources (#148)¶
The claim has a door since #561. For a year the only way to write one was to type YAML into a note by hand, and the measurement said what that meant: 0 of 94 notes in the reference vault carried a claim, so every reader below — the timeline, the idea card,
CompareClaims,FindContradiction, the evidence map — was reading an empty field. One gesture on the note's own menu writes it now, and the claim comes back to you afterwards. See the return of a claim.
ClaimSourceSchema (in the pure src/architecture/knowledge/claims/) fills the ClaimSchema slot so Idea.claims is populated, which makes maturitySignals.hasSources and the unsourced query meaningful. It powers "which ideas make a claim with no evidence?" and feeds the research actions (#155).
Declaration convention — hybrid, gated by declaration:
| The note declares… | Result |
|---|---|
explicit claim / claim:: fields | those become claims, each carrying the note-level sources |
only sources / source:: (no explicit claim) | one synthesized note-level claim (text = note basename) carrying those sources |
| neither | claims = [] — the note stays out of the claims accounting (index/MOC notes aren't flagged) |
Sources are classified by kind (additive optional Source.kind): a [[link]] → { kind: "link", ref: <resolved path> } (alias/heading stripped, unresolved excluded); free text (URL / DOI / citation) → { kind: "text", ref: <trimmed> }. A source declared in both frontmatter and inline collapses to one (dedupe by resolved path / normalized text). Per-claim source binding is deferred — every claim on a note shares the note-level sources.
Parsing is hybrid, reusing #147's infrastructure exactly: frontmatter claims/sources are read in the synchronous build; inline claim:: / source:: [[X]] ride the same deferred, opt-in parseInlineRelations pass (one cachedRead per file — no second scan, no new setting). The pure schema is Obsidian-free (guarded); link resolution lives in snapshot.ts / KnowledgeIndex.ts.
Claim-aware queries: unsourced = notes with claims.length > 0 && !hasSources; claimsWithoutSources gives the claim-granular view; sourcesByReferenceCount ranks sources by how many notes cite them.
Capability note: read-only, like the rest of the model — claims/sources are derived from notes, never written. Authoring actions (attach source, extract claims) are #155.