Status: proposed · Supersedes: three exploratory designs (crown-jewels-first, product-minimalist, substrate-maximalist) and their adversarial review · Audience: nexia-list core contributors · Companion to: ADR-0003 (λδ), the λδ spec v0.1, and the ROADMAP.
A note is a letter we send to our future self. This document is about giving that letter two new senses: the ability to recall its own forgotten neighbours, and the ability to reason over its own claims — both entirely on-device, both invisible until asked for.
We steal the intelligence and primitives of Flying Logic and DEVONthink, not their feature sprawl. Two crown jewels justify genuinely new code in the Rust core; everything else is either already free in nexia's model, or ships later as .ld domain packages and Agent-DSL sugar.
| From | Crown jewel | Becomes | New native code |
|---|---|---|---|
| DEVONthink | See Also — local associative recall | One concordance / inverted index → cosine kNN, BM25 search, Rocchio classify, SimHash dedup, auto-tag | core/src/index.rs |
| Flying Logic | Live confidence propagation — a spreadsheet for reasoning | One pure single-pass DAG sweep over a separate, additive typed-edge channel; junctors are ordinary :type-tagged notes; operators are a native table a domain pack extends |
core/src/edge.rs, core/src/reason.rs, later core/src/layout.rs |
Both are derived, rebuildable indices that live beside the notes and are never trusted from disk — the exact discipline nexia already proved with Notebook::backlinks. Both surface as no-parenthesis L0 panels that mount only when non-empty, and both are reachable as pure λδ builtins for power users. Neither is a monolithic subsystem: each is a Rust module + a handful of host builtins + a derived view.
The recommended first PR is headless: core/src/index.rs alone, property-tested against the existing golden fixtures, with zero UI and zero on-disk format change (§8).
Both external tools collapse onto one spine:
A typed, attribute-rich graph + a local intelligence engine + λδ programs — all local-first, all derived-and-rebuildable, all invisible by default.
The move that unifies them is the move nexia already made for backlinks:
- A derived index lives beside the notes and is rebuilt on load, never authoritative on disk (the concordance for DEVONthink; the confidence map and the layered layout for Flying Logic).
- Semantics are data, dispatched on
:type/:opthrough λδ multimethods (defmulti … :type), so operators, entity classes and whole methodologies ship as.ldpackages without recompiling the core. - The note model is untouched.
Note.linksstays the untyped associative fabric. A parallel, empty-by-defaultedgeschannel carries implication. Existing notebooks round-trip byte-for-byte. - We keep the intelligence, not the interpreter tax. A native operator table is the fast default for the propagation hot path; the λδ
combinemultimethod is the extension path for exotic domain operators only (never called per-node in the inner loop). - Progressive disclosure holds the line. Typed reasoning is opt-in at L1+, never imposed. Flying Logic's mandatory entity-class discipline is the explicit anti-pattern.
The result: the letter still reaches a richer future self — now able to reason over its own claims and recall its own forgotten neighbours, entirely on-device.
These exclusions are load-bearing, not incidental. "Do fewer things well" is a design constraint we enforce, and several of these were flagged by the review as active hazards to avoid.
| Excluded | Reason |
|---|---|
| OCR (image/PDF → text) | Requires Tesseract-class engines and binary pipelines. Content is plain text; Markdown rendering is the only planned rich step. |
| RSS / feeds, web clipper / Sorter, email import | Each needs network fetch, OS integration, or proprietary binary parsing — breaks no-network-by-default. Manual Markdown paste is the only sympathetic path. |
| Multi-device / cloud sync, Server/Sharing edition | Explicit ROADMAP non-goals (no cloud-only storage, no real-time collaboration). Persistence stays IndexedDB + file import/export; users sync exported JSON/vault themselves. |
| Rich binary document handling (PDF annotation, RTFD, web archives) and format-conversion actions | Different product category, heavy WASM burden. The in-scope sliver is Markdown + the existing exchange.rs vault. |
| PDF / PNG / MS-Project diagram export | Heavyweight rendering plus PM-suite interop, against local-first / no-lock-in. Markdown + OPML already cover interop; at most a client-side SVG snapshot of the layout view. |
| Mandatory generative AI (chat, DALL·E, image description), macOS side-effects (Speak / Bounce / SendMail), AppleScript/JXA host | The λδ sandbox is deliberately no-I/O. λδ (homoiconic, budgeted, in-core) is the scripting escape hatch, not an OS bridge. Local models (Ollama/llama.cpp) are opt-in host capabilities only; no data leaves the device by default. |
| Excluded form | What we do instead |
|---|---|
Promoting Note.links: Vec<NoteId> → Vec<Link> (typed links in the load-bearing field) |
Verified to touch ~6 sites (notebook.rs:75/105/260 backlink loops, wikilink derivation, the linksto: agent term, NoteView.links, note_to_value) and to merge L0 associations with causal edges into one vector. We add a separate edges: Vec<Edge> channel instead — same capability, near-zero blast radius, byte-identical round-trip. |
| Per-node λδ multimethod dispatch inside the propagation sweep | Each i.apply decrements the 1M-step Budget and readers hold nb.borrow(), so a combine method touching any mutator panics via RefCell double-borrow — and it is materially slower at 10k nodes on every spinner drag. We keep a native operator table for the hot path; λδ combine is reserved for exotic/domain ops (§4.4). |
| The six TOC Thinking-Process templates (CRT, Evaporating Cloud, FRT, PRT, Transition, S&T) as engine features | They are curated domains + starter graphs. Shipping them in core imposes one methodology and violates untyped-by-default. Ship as optional .ld domain packs + starter notebooks (data). |
| Full probability/arithmetic operator sprawl (Product ×, Sum-Prob ⊕, Proportion ∷, float-flow typing) on day one | Fuzzy min/max/complement + edge weights cover ~80% of reasoning. The rest is trivially added later as combine methods; there is no cost to waiting. |
| A full Dempster–Shafer belief engine | General DS combination is O(2^|frame|) per node with power-set mass functions and conflict renormalisation — non-interactive at 10k. A DS-lite belief/plausibility interval is offered as an optional domain only (§5.4). |
| Exotic confidence math as the default | The default scalar is fuzzy-boolean in [0,1] with 0.5 = Indeterminate. Probability and DS-lite are per-domain opt-ins, never mixed inside one graph (§5.4). |
| Persisting the concordance or propagation results to disk as authoritative state | Bloats the human-readable JSON and risks drift. Both are rebuilt on load, exactly like backlinks. Derived state is never trusted from disk. |
| Animated incremental layout, presentation/step-through mode, typed-attribute schemas as a prerequisite | Pure polish or decoupled workstreams. The static layered view + spinners already deliver the capability; :confidence/:op/:weight ride the existing untyped JSON attrs fine. |
The whole delta is: one new struct + one new field on Note, three derived indices + a triggers collection on Notebook, and a few new Agent-DSL predicates. Everything else rides attributes and :type, which the host already surfaces.
Note.links (untyped association: wikilinks, backlinks, the L0 graph) stays exactly as it is and does not participate in confidence flow. Implication lives on a new, empty-by-default field so that a notebook with no reasoning graph serialises identically to today.
// core/src/edge.rs (new)
use crate::note::NoteId;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// A typed, weighted, directed implication edge — where Flying Logic's logic lives.
/// `kind` is a bare string so domains add edge classes as DATA, never as a core enum.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Edge {
pub to: NoteId,
/// Edge class / dispatch tag: "implies", "supports", "inhibits", "feeds",
/// "contains", … Default "link" ⇒ a typed edge with no ceremony.
#[serde(default = "Edge::plain_kind", skip_serializing_if = "Edge::is_plain")]
pub kind: String,
/// Influence weight in [-1, 1]. +1 passes through, 0 → Indeterminate (0.5),
/// -1 negates (Flying Logic's weighting transform). Default +1.
#[serde(default = "Edge::unit", skip_serializing_if = "Edge::is_unit")]
pub weight: f64,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub attrs: HashMap<String, serde_json::Value>,
}
impl Edge {
fn plain_kind() -> String { "link".into() }
fn is_plain(k: &str) -> bool { k == "link" }
fn unit() -> f64 { 1.0 }
fn is_unit(w: &f64) -> bool { (*w - 1.0).abs() < f64::EPSILON }
}// core/src/note.rs — ONE new field on `Note`, empty for every existing note
/// Typed/weighted implication edges (the reasoning graph). Distinct from
/// `links` (untyped association). Empty ⇒ the note is invisible to the
/// reasoning engine and serialises to nothing new.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edges: Vec<Edge>,Why this beats the untagged Link enum. An untagged serde shim on the load-bearing links field is the highest-risk round-trip against the golden fixtures (untagged ambiguity, poor error messages). An empty-by-default Vec<Edge> guarded by skip_serializing_if = "Vec::is_empty" is provably byte-identical for every existing notebook: no new key appears until a note actually has an edge. Note::new initialises edges: Vec::new().
Back-compat proof obligation (Phase gate). The golden-fixture suite (golden.rs) must show that every existing fixture serialises to the same bytes after this field is added. This is a mechanical, checkable gate — not a hope.
The λδ host already derives :type from attributes["type"], and note_matches already supports attr:type=junct. So junctors, entity classes and groups are ordinary notes distinguished by attributes — no schema work.
| Concept | Encoding | Surfaced as |
|---|---|---|
| Entity / claim node | attributes.type = "claim" (or a domain class) |
:type |
| Combining operator | attributes.op = "and" | "or" | "not" | … |
:op |
| Asserted driver confidence | attributes.confidence = 0.8 (f64 ∈ [0,1]) |
:confidence; editor spinner |
| Entity/edge styling | attributes.type / Edge.kind |
render / edge-style multimethod |
| Group / collapsible subgraph | attributes.type = "group", members via :contains edges |
deferred (§8, L4) |
Computed confidences on driven nodes are never persisted — they would masquerade as asserted drivers and bloat the JSON. Propagation is derived, exactly like backlinks.
Rebuildable and incremental, modelled on backlinks, but marked #[serde(skip)] and always rebuilt on load — the strongest possible "don't destabilise L0" guarantee: zero on-disk format change.
// core/src/index.rs (new) — powers See-Also, Classify, dedup, and BM25 search
use crate::note::NoteId;
use std::collections::HashMap;
type TermId = u32;
type DocId = u32; // DENSE note index — the decisive WASM-memory move (§7)
#[derive(Clone, Copy)]
struct Posting { doc: DocId, tf: u32 }
#[derive(Default)]
pub struct SimilarityIndex {
interner: HashMap<Box<str>, TermId>,
vocab: Vec<Box<str>>,
// note ↔ dense id — postings store an 8-byte (DocId, tf), NEVER a 16-byte Uuid
doc_of: HashMap<NoteId, DocId>,
note_of: Vec<NoteId>,
postings: Vec<Vec<Posting>>, // TermId → postings, sorted by doc
df: Vec<u32>, // document frequency per term
forward: Vec<Vec<(TermId, u32)>>, // DocId → (term, tf) vector, for reindex + norms
doc_len: Vec<u32>, // token count per doc (BM25 |d|)
doc_norm: Vec<f32>, // cached L2 norm of the tf-idf vector
simhash: Vec<u64>, // near-dup fingerprint, FIXED seed
content_hash: HashMap<[u8; 32], Vec<DocId>>, // exact-dup buckets (blake3)
n_docs: u32,
total_len: u64, // for avgdl
}The dense-DocId rule is mandatory, not an optimisation. Postings must store (u32, u32), never the 16-byte Uuid. This is what keeps the index near ~35–50 MB at 10k notes (§7) — comfortable in a browser tab.
Tokenisation is deterministic (Unicode word segmentation → lowercase → stop-list → optional Porter stem), with a fixed seed for SimHash and no clock/RNG — honouring the λδ sandbox and IndexedDB determinism. Crates are Rust-only via cargo (unicode-segmentation, rust-stemmers, blake3), so the Deno-only-for-JS MUST is untouched; BM25/cosine/NB are hand-rolled. A hand-rolled ASCII tokenizer + static stop-list is the zero-dep fallback if WASM size ever matters.
// core/src/notebook.rs
pub struct Notebook {
notes: HashMap<NoteId, Note>,
#[serde(default)]
backlinks: HashMap<NoteId, HashSet<NoteId>>, // unchanged (plain links)
/// Reverse index for TYPED edges, rebuilt exactly like `backlinks`.
#[serde(default)]
in_edges: HashMap<NoteId, Vec<EdgeRef>>, // (source note, edge index)
/// The concordance. DERIVED — never persisted, rebuilt on load.
#[serde(skip)]
index: crate::index::SimilarityIndex,
#[serde(default)]
agents: Vec<Agent>,
/// Smart-Rule triggers (event → predicate → λδ action). Empty by default.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
triggers: Vec<crate::trigger::Trigger>,
pub name: String,
pub created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
}rebuild_backlinks becomes rebuild_indices (keeping the old name as a thin caller): it rebuilds backlinks, in_edges, and index from the notes. WasmNotebook::from_json already calls the rebuild path, so all three are populated on every load.
set_content is the primary write path and already re-derives wikilinks per edit. Reindexing hangs off it:
pub fn set_content(&mut self, id: &NoteId, content: impl Into<String>) -> Vec<NoteId> {
let content = content.into();
// … existing wikilink logic …
self.index.reindex(*id, &self.title_of(id), &content); // NEW
// …
}reindex diffs the note's previous forward vector against the new token bag — decrement df and remove postings for dropped terms, add postings for new terms, recompute this doc's doc_len, doc_norm, and simhash. Cost is O(tokens in the edited note), not the corpus.
Resolving the title-path desync (open tension). Titles dominate short-note similarity, yet update_title / bi_set_title bypass set_content. Both title write paths must call index.reindex(id, new_title, current_content) too. This is a required part of the first PR's contract, not a follow-up.
Resolving global-idf / norm drift (open tension). Every edit changes df/N, which technically shifts the global idf and thus every other note's L2 norm. We do not chase this per-edit. Instead:
doc_normis cached per note and recomputed only for the edited note onreindex.- See-Also and BM25 compute idf against the current
df/Nat query time, so ranking is always consistent with the live corpus even though cached norms lag slightly. The lag affects only the magnitude of the cosine denominator for unedited notes, never their term content, and#[serde(skip)]+ rebuild-on-load bounds any accumulated drift to a single session. - A cheap periodic/opportunistic full
rebuild_indices(e.g. on notebook open, already free) resets norms exactly. We accept bounded intra-session staleness as the honest, documented trade — it is imperceptible in top-k ordering and it keeps edits O(tokens).
Agent { query } is a Smart Group, 1:1, already shipping — and replicants are free (§6). We only enrich the parser:
// core/src/agent.rs — extend `enum Term`
enum Term {
Text(String), Title(String), Attr(String, String), LinksTo(NoteId), Never,
Type(String), // type:junct — surfaces the FL entity class
Similar(NoteId), // similar:<uuid> — DEVONthink See-Also as a saved query
Near(NoteId), // near:<uuid> — cosine ≥ τ (near-duplicate)
Conf(Ordering, f64), // conf:>0.7 — filter by propagated confidence
Edge(String), // edge:supports — has an outgoing typed edge of kind
}similar:/near:/conf: need the index or the propagation result, which the pure note_matches (which has no notebook context) cannot supply. Resolution: add a notebook-aware sibling note_matches_in(note, query, &Notebook) used by run_query; the pure note_matches stays for index-free terms. The whitespace-ANDed L0 surface is unchanged; these are progressive-disclosure power operators.
// core/src/trigger.rs (new)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trigger {
pub id: Uuid,
pub event: TriggerEvent,
pub condition: String, // an Agent-DSL predicate (reuses note_matches)
pub action: String, // a λδ Action-context program
#[serde(default = "yes")] pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TriggerEvent {
OnCreate, OnSetContent, OnSetAttr, OnLink, OnTag, OnRunAgent,
Scheduled { every: String }, // "hourly" | "daily" | "workdays" — host-driven tick
}The local-first subset of DEVONthink's event taxonomy. Networked events (OnDownload/OnScan/OnOCR/OnSync) are dropped. The host fires a matching trigger's action in Action context under a fresh Budget. Triggers travel with the notebook, like agents.
The kernel already implements defmulti/defmethod, register_builtin, a PRELUDE eval'd on Interp::new(), and note_to_value deriving :type. We extend the bridge, add builtins, and register a small operator prelude.
Keep the bridge the single canonical translator; add keys additively (:links unchanged):
// lambdadelta_host.rs :: note_to_value — additive pairs
(Value::kw("edges"), edges_value(¬e.edges)), // vector of {:to :kind :weight :attrs}
(Value::kw("confidence"), num_or_nil(note.attributes.get("confidence"))),
(Value::kw("op"), note.attributes.get("op").map(json_to_value).unwrap_or(Value::Nil)),Pure readers (legal in Formula / Agent-predicate / Agent-query contexts — deterministic, budgeted, no mutation):
| Builtin | Returns | Backed by |
|---|---|---|
(see-also n) / (see-also n k) |
top-k similar note maps | index.see_also |
(similar? a b) |
cosine ∈ [0,1] | index.cosine |
(classify n) |
ranked [class score] |
Rocchio centroid |
(duplicates n) |
exact + near duplicates of n |
blake3 + SimHash |
(search-ranked q) |
notes ranked by BM25 | index.bm25 |
(confidence n) |
asserted-or-computed truth of n in [0,1] |
reason::propagate (memoised per eval) |
(propagate) |
map {#uuid → float} over driven notes |
reason::propagate |
(drivers) / (driven) |
notes with no / some in-edges | reasoning graph |
(edges n) / (in-edges n) |
typed edges of / into n |
Note.edges / in_edges |
!-mutators (Action context only — budgeted, undoable):
| Builtin | Effect |
|---|---|
(set-confidence! id v) |
assert a driver's :confidence |
(link-typed! from to kind weight) |
push an Edge |
(junct! op & operand-ids) → junct id |
create {:type :junct :op op}, wire operands → junct |
(add-tag! id t) / (remove-tag! id t) |
maintain attributes.tags (a Vec) |
(auto-classify! id thresh margin) |
apply top class only if top ≥ thresh ∧ top − runner-up ≥ margin |
Readers register via reader(…), mutators via mutator(…), sharing the existing Rc<RefCell<Notebook>> — line-for-line with the current host bindings.
| Context | Bound | May mutate? | New usage |
|---|---|---|---|
Formula (pure, self) |
self |
no | (:confidence (propagate)), (see-also self 5), (classify self) |
Agent-predicate (self, truthy) |
self |
no | similar:/conf:/type: compile here |
| Agent-query (→ notes) | — | no | (filter #(> (first-score (see-also % 1)) 0.6) (notes)) |
Action (self, mutating) |
self |
yes | (propagate!), (auto-classify! …), (link-typed! …) |
| Trigger-action (new) | self, event |
yes | Smart-Rule bodies; self = triggering note |
The existing L0 agent DSL still compiles to an Agent-predicate λδ expression. see-also/classify/propagate are pure and legal in fx fields (L1); auto-classify! and trigger bodies are Action-context, so a probabilistic model can only touch data through the same audited, undoable seam as any mutator.
Three multimethods carry the transferred intelligence and make domains pluggable as .ld packages:
;; operator algebra — the FL crown jewel's SEMANTICS, extensible without core edits
(defmulti combine (fn [op _inputs] op))
(defmethod combine :and [_ xs] (reduce min 1.0 xs)) ; weakest link
(defmethod combine :or [_ xs] (reduce max 0.0 xs))
(defmethod combine :not [_ xs] (- 1.0 (first xs)))
(defmethod combine :default [_ xs] (reduce min 1.0 xs)) ; entity default = AND-ish
;; rendering — one card per entity/edge class
(defmulti render :type)
(defmethod render :junct [n] {:shape :diamond :label (:op n)})
(defmethod render :claim [n] {:shape :rounded-box :spinner (:confidence n)})
;; edge styling / domain validation
(defmulti edge-style (fn [e] (:kind e)))
(defmulti validate :type)The seam contract (resolving the open tension explicitly). The propagation sweep in reason.rs runs a native Op table in Rust for the known operators {:and :or :not} (and, once shipped, the probability pack's ops). It calls the λδ combine multimethod only when a node's :op is not in the native table. The contract:
- Native is the source of truth for shipped ops.
combine's λδ methods for:and/:or/:notexist for REPL inspection and must be definitionally identical to the native table (min / max / 1−x). A conformance test asserts native and λδ agree on a sample grid, so crossing the seam never silently changes semantics. - λδ
combineis never called per-node for a known op. This is what avoids the verifiedBudgetdrain andRefCellre-borrow panic, and keeps 10k-node spinner drags interactive. - A domain pack adds an op by adding a
combinedefmethod. Unknown ops resolve through λδ and are accepted as slower; if a domain wants its op on the fast path, it registers a native op id via the host capability (a documented, reviewed extension point), not by patching the evaluator.
This makes the native table a floor, not a ceiling: fast by default, infinitely extensible through data.
A domain is a .ld package that registers a set of :type/:op tags plus their combine / render / validate / edge-style methods and a suggested attribute vocabulary — aligning 1:1 with the kernel/host + plugin-package design (issue #33). Flying Logic's "domain = swappable class+operator pack" and DEVONthink's Classify-by-corpus both become data, not core enums. Shipped examples (§8, L2/L4): flying-logic.ld (junct/claim/group types + operator algebra + render cards), librarian.ld (confidence-gated auto-classify trigger + kNN auto-tag), and later the TOC methodology packs and a probability operator pack.
| Flying Logic capability | nexia mechanism | Cost |
|---|---|---|
| Entity = statement node | Existing Note; class = attributes.type → :type |
free |
| Directed causal/logical edge | New Note.edges: Vec<Edge> { kind, weight, attrs } |
S (struct) |
| Junctor (AND/OR bundler) | A note {:type :junct :op …}; operands → junct → target via typed edges |
free (data) |
| Necessary vs sufficient | :op :and = min, :op :or = max, selected in combine |
free |
| Weighted / negated edge | Edge.weight ∈ [-1,1] + the weighting transform (w=0→0.5, w<0→negation) |
free |
| Confidence scalar | attributes.confidence: f64; 0.5 = Indeterminate midpoint |
free |
| Live confidence propagation | reason::propagate — single-pass DAG sweep; (confidence n) / (propagate) |
M (crown jewel) |
| Advanced operators (XOR/⊕/×/∷) | native Op table (hot path) + defmethod combine :op for domain ops |
S core, ∞ via packs |
| Domains (class + operator packs) | .ld packages registering :type/:op methods + attr vocab |
pack (data) |
| Entity classes & styling | :type + defmethod render :type; prototype supplies class defaults |
S |
| TOC thinking-process templates | out-of-core domain packs + starter notebooks (§2) | pack |
| Automatic layered DAG layout | new core/src/layout.rs (Sugiyama) → ReasoningView |
M |
| Animated incremental layout | deferred — ReScript interpolation only | — |
| Groups / collapse | {:type :group} + :contains edges; layout/propagate as one node |
M (later) |
| Back-edge cycle handling | DFS marks & excludes back edges; drives a "you made a loop" cue | free (in M) |
| Confidence spinners (live) | SetConfidence msg → set-confidence! → re-propagate → re-render |
S |
| Fuzzy vs float flow typing | :flow tag on computed value; deferred |
— |
| Operators inspector | settings surface writing :op (per-note + notebook default) |
S |
| PDF/PNG/MS-Project export | out (§2); at most a client-side SVG snapshot | — |
core/src/layout.rs runs a compact Sugiyama pipeline over the typed-edge graph and returns positions that are computed, never stored — Note.position stays owned by the manual spatial canvas, so auto-layout and free-canvas coexist:
- Cycle removal — reuse
reason.rs's back-edge set (temporarily reverse those edges). - Layer assignment — longest-path layering; insert dummy nodes on edges spanning >1 layer.
- Crossing minimisation — median/barycenter, a few up/down sweeps.
- x-coordinate assignment — Brandes–Köpf for straight, balanced edges; y from layer index; honour an orientation flag.
Exposed as reasoning_layout(orient) -> [{id, x, y, layer}]. It replaces the GraphView placeholder and reuses GraphLayout.res's nodePos seam — the ReScript view only draws; the layout comes from Rust. Animated transitions are deferred polish.
// core/src/reason.rs
pub type Conf = f32; // [0,1]; f32 halves map memory
/// Flying Logic's documented weighting transform, applied per in-edge
/// BEFORE the operator: w=+1 pass-through, w=0 → 0.5, w=-1 → negation.
#[inline]
fn weight_edge(v: Conf, w: f32) -> Conf {
let s = 2.0 * v - 1.0; // [0,1] → [-1,1]
(s * w + 1.0) * 0.5 // → [0,1]
}
#[derive(Clone, Copy)]
pub enum Op { And, Or, Not, /* pack: */ Xor, Product, SumProb, Proportion }
/// Combine already-weighted inputs. Empty input set ⇒ 0.5 (Indeterminate).
fn combine(op: Op, xw: &[(Conf, f32)]) -> Conf {
if xw.is_empty() { return 0.5; }
match op {
Op::And => xw.iter().map(|p| p.0).fold(f32::INFINITY, f32::min), // weakest link
Op::Or => xw.iter().map(|p| p.0).fold(f32::NEG_INFINITY, f32::max),
Op::Not => 1.0 - xw[0].0,
Op::Product => xw.iter().map(|p| p.0).product(),
Op::SumProb => xw.iter().fold(0.0, |a, &(b, _)| a + b - a * b), // 0.5⊕0.5=0.75
Op::Proportion => { // zero-weight abstains
let (num, den) = xw.iter().fold((0.0, 0.0), |(n, d), &(x, w)| {
let a = w.abs(); (n + a * x, d + a)
});
if den == 0.0 { 0.5 } else { num / den }
}
Op::Xor => fuzzy_xor(xw), // max_i min(x_i, 1 - max_{j≠i} x_j)
}
}
/// ONE deterministic pass. (1) DFS marks back edges (edges to a node on the
/// stack) and drops them. (2) Kahn topo over the DAG. (3) Sweep: drivers keep
/// their asserted value; driven nodes combine weighted inputs. O(V+E), no fixpoint.
pub fn propagate(nb: &Notebook) -> HashMap<NoteId, Conf> {
let g = ReasonGraph::build(nb); // typed edges only, nodes/edges SORTED (§9)
let back = g.back_edges(); // DFS over a sorted adjacency
let order = g.kahn_topo(&back);
let mut conf = HashMap::with_capacity(order.len());
for id in order {
let ins = g.in_edges(id, &back);
let c = if ins.is_empty() {
asserted(nb, id).unwrap_or(0.5) // DRIVER
} else {
let xw: Vec<(Conf, f32)> = ins.iter()
.map(|e| (weight_edge(conf[&e.from], e.weight as f32), e.weight as f32))
.collect();
combine(op_of(nb, id), &xw) // DRIVEN
};
conf.insert(id, c);
}
conf
}Why fuzzy min/max weakest-link is the default:
- Monotone, order-independent, single-pass. It evaluates in one topological sweep, O(V+E), with no fixpoint iteration — the only family that stays interactive under spinner-drag at 10k nodes.
- 0.5 = Indeterminate is a better "unknown" than
null. A principled, bounded, neutral midpoint that the whole engine runs on. - Negation is a number. The edge-weight transform gives graded support (w<1), abstention (w=0 → 0.5), and logical negation (w=−1) with no extra node type.
- Back edges are drawable but excluded. Feedback loops render (dashed, with a "you made a loop" cue) yet never break evaluation, because the evaluated graph is a DAG.
Made domain-overridable (resolving the semantics tension): pluggability is layered so two domains can never disagree inside one graph:
- The combine function is chosen per node by
:op(native table + λδ extension, §4.4). - The scalar semantics (fuzzy vs probability vs DS-lite interval) is chosen per domain package, and a notebook's reasoning graph declares its domain once. A note tagged with a different domain's scalar type is a validation error surfaced by
defmulti validate :type— the engine refuses to silently mix a fuzzy 0.7 with a DS mass function. Fuzzy is the default when no domain is declared.
For spinner drags, cache order and each node's downstream-reachable cone; on a driver change, re-sweep only the affected suffix. A full sweep at ~10k nodes / ~30k edges is sub-millisecond in WASM; a single-driver drag touches only its downstream cone.
| DEVONthink capability | nexia mechanism | Cost |
|---|---|---|
| See Also | index.see_also (concordance + TF-IDF + cosine kNN); (see-also n k); L0 "Related notes" panel |
M (crown jewel) |
| Classify | index.classify (Rocchio centroid; NB as a pack); (classify n); "Suggested tags" chips |
M |
| Auto-Classify | auto-classify! — apply top class only if top ≥ τ ∧ top−runner ≥ margin; Action-context, undoable, opt-in |
S (on Classify) |
| Concordance / inverted index | core/src/index.rs::SimilarityIndex — the one shared substrate, rebuilt like backlinks |
M (foundational) |
| Smart Groups (dynamic saved search) | already shipping = Agent { query }; enrich DSL (similar:, near:, conf:, ranked) |
free → S |
| Smart Rules — triggers | Notebook.triggers; host fires TriggerEvent → predicate → λδ action; local-first subset |
M |
| Smart Rules — actions | λδ Action builtins (set-attr!, link-typed!, add-tag!, auto-classify!, create-note!) |
S (mostly exist) |
| Replicants | free — one Uuid; membership = which agents/tags match; edits shared because one object |
free |
| Duplicate detection | index.duplicates — blake3 exact + SimHash/LSH near; (duplicates n) + panel |
S (on index) |
| Tags + hierarchical groups | tags = attributes.tags (multi-valued) via tag:/attr:; hierarchy = agents + prototype |
S |
| Full-text search + operators/proximity | BM25 over the index; boolean/phrase/proximity via positional postings; L0 stays whitespace-ANDed | M |
| OCR / rich binary formats / RSS / web-clip / email / sync | out (§2) | — |
| Optional generative AI | opt-in λδ host capability (Ollama/llama.cpp); never default, no data leaves device | later, opt-in |
One index, five features — the concrete pipeline:
impl SimilarityIndex {
/// tf-idf weight = (1 + ln tf) · ln(N / df), idf against CURRENT df/N.
#[inline] fn w(&self, tf: u32, df: u32) -> f32 {
(1.0 + (tf as f32).ln()) * ((self.n_docs as f32) / (df as f32)).ln()
}
/// See Also: top-k by cosine, self excluded. Walk only the query note's
/// top ~40 terms; accumulate partial dot-products over their postings.
pub fn see_also(&self, id: NoteId, k: usize) -> Vec<(NoteId, f32)> {
let d = self.doc_of[&id];
let mut score: HashMap<DocId, f32> = HashMap::new();
for &(t, tf) in top_terms(&self.forward[d as usize], 40) {
if (self.df[t as usize] as usize) > self.n_docs as usize / 2 { continue; } // freq-stop
let wq = self.w(tf, self.df[t as usize]);
for p in &self.postings[t as usize] {
if p.doc == d { continue; }
*score.entry(p.doc).or_default() += wq * self.w(p.tf, self.df[t as usize]);
}
}
let mut out: Vec<_> = score.into_iter()
.map(|(doc, dot)| (self.note_of[doc as usize],
dot / (self.doc_norm[d as usize] * self.doc_norm[doc as usize])))
.collect();
out.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); // deterministic tie-break (§9)
out.truncate(k);
out
}
/// BM25 (k1≈1.2, b≈0.75) — replaces naive substring search, stands in for tantivy.
pub fn bm25(&self, query: &str, k1: f32, b: f32) -> Vec<(NoteId, f32)> { /* … */ }
/// Rocchio nearest-centroid classify: cosine of note to each class centroid.
/// Cheaper and incrementally updatable vs multinomial NB (offered as a pack).
pub fn classify(&self, id: NoteId, centroids: &[(ClassId, SparseVec)]) -> Vec<(ClassId, f32)> { /* … */ }
/// Duplicates: exact via blake3 buckets; near via SimHash Hamming ≤ 3,
/// candidates gathered by LSH bands (no O(n²) scan).
pub fn duplicates(&self, id: NoteId) -> Duplicates { /* … */ }
}- See-Also: TF-IDF sparse vectors, cosine, top-k walking only the query note's ~40 heaviest terms with a frequency stop-list — single-digit ms at 10k.
- Classify / Auto-Classify: each Agent (or tag value) is a class; Rocchio centroid is the default (incrementally updatable), multinomial naive-Bayes ships as a pack. Auto-Classify is the confidence-gated automation pattern: apply only when
top ≥ threshold ∧ top − runner-up ≥ margin, else defer to the human — routed throughauto-classify!so it is undoable, budgeted, opt-in, never silent. - Duplicates: blake3 exact-hash buckets + 64-bit SimHash (fixed seed) with LSH banding for near-dups; offer merge or "these are the same note".
- Auto-tag: kNN label propagation over See-Also neighbours (weighted vote above a threshold) — reuses the similarity engine, no separate model.
- Ranked search: BM25 over the same index with positional postings for phrase/proximity. The L0 whitespace-ANDed syntax is the default surface; boolean/phrase/
NEAR/nare progressive-disclosure power operators.
- Smart Groups = Agents. Already shipping, 1:1. The only work is enriching the DSL as the index lands (§3.6).
- Smart Rules = triggered λδ actions.
TriggerEvent→ Agent-predicate condition → λδ Action body — DEVONthink's event→condition→action grammar, native to the homoiconic core. - Replicants are free. A
Notehas oneUuid; "membership" is which Agents match and which tags it carries. A single note already appears in every matching Agent view and every tag facet, with edits inherently shared because there is one object. Nothing is ever copied.
The ROADMAP commits Phase 3/5 to tantivy. We consciously deviate: tantivy gives BM25 but not See-Also cosine, Rocchio classify, or SimHash dedup. Running tantivy and a separate vector index means two indices and more memory. We therefore hand-roll one concordance for all five features, accepting the maintenance burden of our own BM25/phrase/proximity implementation as the price of the shared substrate — a hand-rolled index is comfortably sufficient at 10k notes. This retires the "tantivy planned but not built" seam. We also reframe ROADMAP Phase 8 "Intelligence": See-Also/Classify are the deterministic local heart of that phase; any LLM is strictly optional and opt-in, never the default — a positioning this document asks the ROADMAP to adopt.
| Concern | Choice |
|---|---|
| Tokenisation | Unicode word segmentation → lowercase → stop-list → optional Porter stem; deterministic, fixed data |
| Term weighting | TF-IDF (1+ln tf)·ln(N/df); L2-normalised sparse vectors |
| Similarity | cosine (dot of normalised vectors) |
| Search ranking | BM25, k1≈1.2, b≈0.75, positional postings for phrase/proximity |
| Classify | Rocchio nearest-centroid (default); multinomial NB (pack) |
| Dedup | blake3 exact + 64-bit SimHash (fixed seed) + LSH bands |
| Propagation | edge-weight transform + native operator table; DFS back-edge exclusion + Kahn topo; single pass |
| Layout | Sugiyama (longest-path layering → barycenter → Brandes–Köpf), positions derived |
| Operation | Cost | Notes |
|---|---|---|
| Full index build | O(Σ tokens) ≈ 3M | tens of ms; on load / import only |
| Incremental reindex (one edit) | O(tokens in note) | in set_content/title path; imperceptible |
| See-Also query | O(top-terms × avg df) | 40-term cap + freq-stop → single-digit ms |
| BM25 search | O(Σ df of query terms) | ms |
| Classify (Rocchio) | O(terms(d) × #classes) | ms |
| All-pairs dedup | ~O(N) via LSH bands | not O(N²) |
| Full propagation sweep | O(V+E) ≈ 40k ops | sub-ms in WASM |
| Spinner-drag recompute | O(downstream cone) | interactive |
| Index memory | ~35–50 MB | inverted ≈16 MB + forward ≈16 MB + simhash 80 KB + norms 40 KB. Requires dense-DocId interning (postings store 8-byte (u32,u32), never 16-byte Uuid). Comfortable in a browser tab. |
All hot operations are incremental: reindex is per-note; propagation re-sweeps only the downstream cone; layout runs on the reasoning subgraph, not the whole notebook. The full build (load, import, or the opportunistic norm-resetting rebuild) is the only O(corpus) operation, and it is tens of ms.
Web Worker escape hatch. Everything is pure, deterministic, allocation-only Rust — no threads/network/clock/RNG (SimHash uses a fixed seed) — so it slots into the wasm-bindgen core and honours the λδ budget. If a very large notebook ever makes the initial build or a full rebuild perceptible, the WASM core can be instantiated in a Web Worker: the TEA loop posts the notebook JSON in, receives the built indices out, and the main thread never blocks. This is a deployment option, not an architectural change — the core code is identical either way.
Aligned to the λδ L0–L4 progressive-disclosure levels and the numbered ROADMAP phases. Each phase is independently shippable; L0 UX (no parentheses, ever) is untouched throughout; new state is either additive-and-skippable or #[serde(skip)]-derived.
Smallest, headless, highest-value. Build core/src/index.rs + tokenizer + the incremental hook in set_content and both title write paths + rebuild_indices. No WASM entry points, no UI. Unit- and property-tested against the existing golden.rs / invariants.rs fixtures, including the byte-identical round-trip proof (the index is #[serde(skip)], so this is automatic) and a determinism test (sorted iteration, fixed-seed SimHash). This de-risks the entire DEVONthink side, fills the "tantivy planned but not built" seam, and lands zero user-visible change. It mirrors the L0-substrate discipline ADR-0003 used for λδ.
| Level / ROADMAP | Ships | New state | User-visible |
|---|---|---|---|
| First PR (ROADMAP Phase 3) | headless index.rs, tokenizer, incremental hooks, rebuild_indices |
#[serde(skip)] index |
none |
| L0 (ROADMAP Phase 3 + differentiator) | See-Also / Suggested-tags / Duplicates panels (mount only when non-empty); BM25 ranked search replaces substring; Edge + reason.rs + confidence spinner in the editor; ReasoningView (layout.rs) with driver/driven shading and dashed back-edges |
edges field (empty by default) |
Related panel; ranked search; spinners; reasoning canvas — all no-code |
| L1 (ROADMAP Phase 4) | pure builtins in fx fields: (see-also self k), (classify self), (:confidence (propagate)); Agent DSL gains similar:/near:/conf:/type:/edge: |
— | computed confidence & similarity in formulas |
| L2 (ROADMAP Phase 4/9) | (propagate!), (link-typed!), (junct!), (auto-classify!), deftrigger; combine/render/validate/edge-style multimethods; Notebook.triggers; ship flying-logic.ld + librarian.ld |
triggers collection (empty by default) |
Smart Rules; domain packs |
| L3 | REPL exploration of (propagate), (see-also …), (duplicates …), (back-edges) against the live graph under budget |
— | power console |
| L4 (ROADMAP Phase 7 Views + Phase 9 Ecosystem) | computational notes rendering See-Also / propagation inline; layout as a defview; TOC / probability / DS-lite domain packs via the plugin system (#33); opt-in local-LLM host capability |
— | shareable packages |
ROADMAP crosswalk. Phase 3 Search → the concordance + BM25 (retires tantivy). Phase 4 Agents → Smart Groups (exist) + Smart Rules (triggers). Phase 7 Views → layered layout replacing the GraphView placeholder. Phase 8 Intelligence → See-Also/Classify are the deterministic local heart; LLM strictly optional. Phase 9 Ecosystem → both tools as .ld domain packages.
Net new native surface: one struct + one Note field, plus small modules index.rs, edge.rs, reason.rs, trigger.rs, and later layout.rs. Everything a Flying Logic or DEVONthink user recognises as intelligence — operators, domains, methodologies, classification policy, organisation rules, reasoning views — ships as λδ multimethods, .ld packages, and Agents on top.
Resolved in this design (documented so the resolution is auditable):
- Determinism under the λδ sandbox. DFS back-edge selection and any HashMap iteration in propagation/index must be order-stable, or golden tests flake and the no-random contract is violated. Resolution: the
ReasonGraphsorts nodes and adjacency lists before DFS/Kahn; See-Also/search sort with an id tie-break (then(a.0.cmp(&b.0))); SimHash uses a fixed seed; tokenisation is pure. A determinism property test guards all four. - Cycle policy on a graph that had no acyclic invariant.
linksnever required acyclicity andlink_notesonly rejects self-links. Resolution: the newedgeschannel defines its own policy — DFS-detected back edges are excluded from flow (single pass, no fixpoint), drawn dashed, and surfaced via a "you made a loop" cue.linksis unaffected. - Native/λδ operator seam. A conformance test asserts the native
Optable and the λδcombinemethods agree for shipped ops, so crossing the seam never changes semantics; unknown ops go through λδ and are accepted as slower (§4.4). - Back-compat migration. Empty-
Vec<Edge>+skip_serializing_ifgives a provable byte-identical round-trip on every golden fixture — the safest of the three considered approaches, and far safer than an untaggedLinkenum on the load-bearing field. - Index memory at 10k. Mandated dense-
DocIdinterning holds it to ~35–50 MB (§7.2). - Incremental consistency. Title paths are hooked; idf/norm drift is bounded to a session by
#[serde(skip)]rebuild-on-load, with idf computed against currentdf/Nat query time (§3.5).
Genuinely open:
- When to trigger the opportunistic full rebuild. On-open is free; do we also rebuild after N edits, or only when a norm-drift heuristic exceeds a threshold? Needs measurement on real notebooks.
- "Promote this association to a typed edge" UX. The clean split means a wikilink can never also be causal; a user who wants both must create a typed edge. The affordance must avoid silent duplication (the same relation appearing once as a link, once as an edge). Design needed.
- Classify class-source ambiguity. Agents and tag values can both be "classes". Which is the default training signal, and how do we present two overlapping class systems without confusing the user?
- Stemming/stop-list quality vs determinism across locales. Porter stemming and English stop-lists markedly improve similarity but are language-specific; the multilingual story (and whether stemming is a per-notebook toggle) is unspecified.
- DS-lite domain semantics. Offering a belief/plausibility interval as an optional domain (§5.4) needs its own operator table and validation rules; the interaction with the native fast path is sketched but not specified.
- Web Worker boundary. If we adopt the off-main-thread build (§7.3), the serialization cost of shuttling a large notebook to the worker must be measured against the main-thread block it avoids.
- Trigger execution ordering and loops. When one trigger's action fires an event another trigger listens for, we need a documented depth/reentrancy limit (the
Budgetbounds a single action, not a cascade).
Steal the intelligence and the primitives, not the feature sprawl. Keep the note model untouched, the semantics as data, the hot path native, the disclosure progressive, and everything on-device. The letter still reaches a richer future self — now able to reason over its own claims and recall its own forgotten neighbours.