| title | Doppl — System Design & Concept Document |
|---|---|
| subtitle | An idea organism: markdown nodes under evolutionary selection |
| author | Synthesised from the Doppl contracts, the capstone proposal, and the doppl-prime/michael repo |
| status | Design reference (v1) |
| date | 2026-06-23 |
One sentence. Doppl turns a case study into a recovered problem and then into one or more actionable answers, by running one engine — generation under selection — along a fixed spine of markdown nodes, where every node is both a human-readable artifact and a typed, parseable record.
This document explains the concept, the data model, the language (MarkScript), the engine, and four concrete ways to implement and visualise it — from a plain GitHub + Obsidian vault to a local Neo4j graph — using your own AI harness (Claude Code, Cursor, etc.) as the generative layer.
- Elevator pitches
- Executive summary
- Technical summary
- The concept: an idea organism
- The tree, the islands, and the forest
- The data model: nodes, stages, contracts
- MarkScript: the language
- The engine: the generate→select crucible
- Rating: measurements, judge, and the human ledger
- Discovery and stock: durable domain memory
- Prior art: where Doppl sits in the landscape
- Implementation: the stack
- The AI harness layer: skills, rules, and the agentic pipeline
- Strengths — conceptual and real
- Drawbacks, risks, and where it breaks
- Phased build plan
- Appendix: glossary and contract index
Every agent system shipped today is a hand-built artifact: a human freezes a prompt, a toolset, and a verification loop, and the agent executes that frozen design. Doppl puts the design itself under selection. Point one engine — generate many candidates, score them on two warring axes, breed the survivors — at the hardest thing to automate: having a genuinely good idea. A case study goes in; a population of recovered problems and non-obvious answers comes out, each one a portable markdown file you can read, link, score, and feed back in as the seed of the next generation. It is not A/B testing and not even A-through-Z testing — it is an open, self-extending alphabet of variants no human wrote, where winning means surviving adversarial scrutiny.
MarkScript is a way to type markdown without killing the markdown. The file stays the authored artifact — a human reads it top to bottom — but every section carries a TypeScript-shaped contract that says exactly what a parser must recover from it. One file, three readers: a human reads the prose, a parser finds the headings and payloads, a validator rejects drift without interpreting vibes. The TypeScript never replaces the markdown; it names what must survive the round trip. This is what lets a folder of notes behave like a typed database while remaining a folder of notes.
The problem. Good ideas have no cheap ground-truth signal. You cannot grade novelty with a unit test, and large language models, left to free-run, converge on confident, fluent, mediocre output ("slop"). Most "idea tools" are a single frozen prompt; they cannot improve, cannot be audited, and cannot tell a real reframe from a paraphrase.
The bet. Manufacture a fitness function out of adversarial verification and use evolution to climb it without collapsing into slop. Treat ideation the way nature treats organisms: a population under selection pressure, where the survivors breed the next generation.
The shape. Doppl runs a fixed three-stage spine and renders each step as one markdown file (a node):
case_study → problem_recovery → doppl → (the human's action)
A case study is a seed (a situation or postulation). Problem recovery strips the surface complaint to the real, hidden problem. A doppl is the finished answer — the unlock — and a single recovered problem can yield several distinct doppls. Each arrow is one pass of the engine; each node is immutable once folded.
Why it is different. Three design decisions carry the whole system:
| Decision | What it buys |
|---|---|
| One kernel, many modes | Discovery, problem-finding, and solution-finding are not three engines — they are one engine at three dial settings (diverge ↔ converge). Build it once. |
| Two-axis fitness, never collapsed | Fitness is novelty × grounding, kept as two separate readings until selection. Pure novelty → confident slop; pure grounding → mode collapse. The tension is the design. |
| Markdown is the artifact, the trace is the truth | The machine emits a clean RunTrace; the markdown node is a projection of it. Humans and agents read the same files; nothing is hidden in a database the reader cannot open. |
Where it runs. Doppl is local-first and harness-agnostic. The "AI part" is supplied by whatever coding agent you already drive — Claude Code, Cursor, or similar — invoked through a small set of skills (the mutation operators) and rules (the invariants). The substrate can be as light as a Git repo of markdown opened in Obsidian, or as rich as a local Neo4j graph for querying and visualising the whole forest of ideas.
What "working" looks like. A seed goes in; the system generates ~20 candidates, scores them, culls the weak, breeds the survivors, and renders a lineage tree of scored nodes a human can judge at a glance — and the same harness does this across many seeds, making the selection behaviour visible in one board.
Doppl is a TypeScript kernel wrapped in a markdown contract layer, driven by an external AI harness, and projected into a graph for storage and visualisation.
- Kernel (
src/). A pure pipeline,buildRunTrace(), composed of four modules with explicit boundary contracts:generate → fitness → select → lens. It does not "pick the best"; it breeds a stronger child from a population and records the run as the specimen. - Fitness. Two orthogonal
0…1measurements —noveltyandgrounding— each a weighted sum of deterministic text/source signals (never model self-grading). They are kept separate through a Pareto-front selection so the tradeoff stays inspectable. - The dial. One knob, two postures: diverge (priority novelty, grounding as a floor) and converge (priority grounding, novelty as a floor). The per-generation schedule of this dial is the application.
- Decay. A third, time axis inside the engine. Currently stubbed to
0(multiplier1), buttemporal: booleanis the live seam where a future half-life mechanism bolts on. - Lens. Observer-relative feasibility, scored after selection, that must never contaminate fitness. A hedge fund and a capstone team weigh the same true idea oppositely.
- Rating. Two number systems:
0…1measurements (instrument readings) map into−5…+5ratings (judgments of worth). The judge fills a five-axis evaluation; the human gives one slider. Human votes live in a separate ledger; the node stores only a materialised projection (scores.human,scores.n). - Memory. The node graph itself is the lineage memory — no separate ledger. Only one fact is stored that the graph cannot re-derive:
doppelgangers, a count of near-duplicates deduped into a node. Convergence (distinct lineages hitting the same target) is a derived query, never stored. - Caps. Finite by construction:
maxGenerations,maxChildrenPerParent,maxPopulation, plus future budget caps on tokens/tool-calls/wall-clock.
Everything below is the long form of those eight bullets, plus four ways to deploy it.
Strip any Doppl operation down and you get the same loop:
generate candidates → evaluate against a fitness signal → keep the strong → generate again from them
That loop is the kernel. What reproduces is pluggable — a thesis, a consequence, a recovered problem-frame, a solution candidate, or (left as a seam) an agent scaffold. The loop never changes; only the unit and the dial do.
flowchart LR
S[Seed / parents] --> G[generate<br/>divergent step]
G --> F[fitness<br/>novelty × grounding]
F --> SEL[select<br/>dial: diverge ↔ converge]
SEL --> L[lens<br/>feasibility, after selection]
L --> C[compile<br/>survivor → next node]
C -->|breed again from survivors| G
SEL -.regret sibling: what the other dial would have kept.-> SEL
The kernel has one master knob: the balance between generation and selection. Turning it gives opposite kinds of search.
| Divergent (generation up) | Convergent (selection up) | |
|---|---|---|
| Move | one seed → many children, fanning out | many signals → collapse to the one |
| Verb | explore, expand, branch | exploit, contract, funnel |
| Priority axis | novelty (grounding is a floor) | grounding (novelty is a floor) |
| Enemy | redundancy / near-copies | premature consensus / slop |
| Doppl pieces | mutation, sprouts, ripple | critic council, culling, selection |
Divergence and convergence are the same operation with the sign flipped. This is why the three "applications" are not three engines:
- Discovery = divergent. "What's out there / what does this unlock?" Dial pinned to explore, graded by novelty.
- Problem identification = convergent. Symptoms → the single hidden variable. Dial pinned to exploit. (This is Doppl's
problem_recoverystage.) - Solution finding = the oscillation. Diverge to generate candidate answers, converge to verify them against critics, diverge to mutate, converge to score. The art is the rhythm of switching.
The mechanism unifies; the fitness signal must not. Divergent and convergent search measure opposite things on two orthogonal, warring axes:
- Novelty — spread, coverage, distance from consensus. Did we reach somewhere new?
- Grounding — truth, falsifiability, evidence. Did we land on the real thing?
You cannot maximise both at once. That tension is the design, not a bug. The entire "manufacture fitness without ground truth" bet lives in the balance between the two failure modes:
quadrantChart
title Two-axis fitness space (novelty × grounding)
x-axis "Low grounding" --> "High grounding"
y-axis "Low novelty" --> "High novelty"
quadrant-1 "The doppl: novel AND true"
quadrant-2 "Confident slop (pure divergence)"
quadrant-3 "Dead: stale and unfounded"
quadrant-4 "Mode collapse (pure convergence)"
"Surface complaint": [0.30, 0.20]
"Paraphrase / rehash": [0.62, 0.18]
"Wild but unfounded": [0.15, 0.85]
"Recovered problem": [0.78, 0.62]
"The winning unlock": [0.82, 0.80]
The valuable target sits in the top-right quadrant: novel and grounded. The two ways to fail are the two ways to drop an axis.
Novelty and grounding describe an idea now. But fitness erodes as the world changes — an observer-independent property of the idea, so it belongs inside the engine, not as a filter on top.
- A cross-domain transfer is timing-agnostic → slow / no decay (
temporal: false). - A zeitgeist synthesis is built on a dated signal → fast decay; its window closes (
temporal: true).
The classic example is BlackBerry: it scored high on adoption and lock-in, but its decay was lethal when the touchscreen regime arrived. So an idea's true fitness is novelty × grounding × decay-rate. Decay is currently stubbed to 0, but the temporal boolean is the live attachment point.
Separate from the engine's intrinsic fitness is the lens — "is this worth it to me, with my resources?" It is observer-relative. Keeping feasibility out of core fitness is exactly what lets one engine serve many users: they share novelty + grounding + decay, and differ only in lens.
Engine = novelty × grounding × decay (intrinsic, shared)
Lens = feasibility / fit (observer-relative, swappable, applied after selection)
This is the structural heart of your question — how the nodes connect.
The spine is fixed and linear per path: case_study → problem_recovery → doppl. But two branch points turn a path into a tree:
- a recovered problem can yield more than one doppl when the answers are genuinely distinct (the "perfect Pepsi vs. the perfect Pepsis"); and
- a case study can recover more than one problem as discovery surfaces different hidden variables.
So a single seed grows a tree whose root is the case study, whose internal nodes are recovered problems, and whose leaves are doppls — the unlocks, the candidate solutions.
flowchart TD
CS["case_study<br/>(seed / root)"]:::seed
PR1["problem_recovery A<br/>refining bottleneck"]:::prob
PR2["problem_recovery B<br/>offtake lock"]:::prob
D1["doppl<br/>back refining capacity"]:::leaf
D2["doppl<br/>toll-processing wedge"]:::leaf
D3["doppl<br/>hedge raw-miner exposure"]:::leaf
CS --> PR1
CS --> PR2
PR1 --> D1
PR1 --> D2
PR2 --> D3
classDef seed fill:#1f2937,color:#fff,stroke:#111;
classDef prob fill:#374151,color:#fff,stroke:#111;
classDef leaf fill:#065f46,color:#fff,stroke:#064e3b;
At any moment a case study and everything it grows is an island — a connected component in the larger graph. The system runs many seeds, so you get an archipelago: several islands, each a tree of postulations, evolving in parallel. (In the evolutionary-computing literature this is literally the island model: multiple populations evolve independently and periodically exchange their best individuals — a design that maintains diversity and resists premature convergence.)
The move that turns a set of trees into a living forest is feedback: a leaf doppl can be reseeded as a new case study. The unlock you found in the battery island ("own the refining bottleneck") becomes the seed of a new island ("what does owning a processing bottleneck unlock elsewhere?"). Cross-island edges appear when convergence is detected — distinct islands arriving at the same attractor — which is a derived query over the graph, never a stored fact.
flowchart LR
subgraph ISLAND_A["Island A — Battery / yuan constraint"]
A0((case_study)) --> A1((problem)) --> A2((doppl: refining))
end
subgraph ISLAND_B["Island B — AI firm power constraint"]
B0((case_study)) --> B1((problem)) --> B2((doppl: grid siting))
end
subgraph ISLAND_C["Island C — Processing-bottleneck pattern"]
C0((case_study)) --> C1((problem)) --> C2((doppl))
end
A2 -. reseed leaf as new seed .-> C0
B2 -. reseed leaf as new seed .-> C0
A2 -. convergence: derived cross-island edge .- B2
classDef x fill:#0f172a,color:#e2e8f0,stroke:#334155;
The takeaway: the data structure is a forest of immutable trees with a feedback edge from leaves to new roots, plus derived (never stored) cross-island convergence edges. Lineage is append-only; nothing is overwritten. This is exactly why a graph store fits so naturally (Section 12).
| Fact | Stored? | How it lives |
|---|---|---|
| Parent → child lineage | Stored | prev: Uuid[], root: Uuid in frontmatter; edges in the graph |
doppelgangers (near-dupes deduped in) |
Stored | the one fact dedup destroys |
| Convergence (distinct lineages → same target) | Derived | a query over the node graph, run as a lens |
| "This keeps coming up" / process-health | Derived | reads doppelgangers piling up on low-rated ideas |
A node is one step of an idea's journey: YAML frontmatter (identity + routing) plus a markdown body. It is a projection of the RunTrace, not a second source of truth. The body always starts with a headline; growth-stage nodes add Trace, Discovery, Growth, and Path.
flowchart TD
NF["NodeFile (.md)"] --> FM["frontmatter<br/>id · stage · next · lineage · scores"]
NF --> BODY["body"]
BODY --> H["# Headline (the one-line result)"]
BODY --> T["## Trace (prior synopses, verbatim)"]
BODY --> DI["## Discovery (what was found)"]
BODY --> GR["## Growth (what was concluded — the SCORED surface)"]
GR --> EV["### Evaluation (judge's 5 axes)"]
BODY --> P["## Path (next stage, or null)"]
The spine is enforced by the type system: next is pinned by stage, so an illegal transition will not type-check. This is the contract idiom — the TypeScript names what must be recoverable from the markdown.
type Stage = 'case_study' | 'problem_recovery' | 'doppl';
type BaseStage<S extends Stage, Next extends Stage | null> = { stage: S; next: Next };
type CaseStudyStage = BaseStage<'case_study', 'problem_recovery'>;
type ProblemRecoveryStage = BaseStage<'problem_recovery', 'doppl'>;
type DopplStage = BaseStage<'doppl', null>;
type StageContract = CaseStudyStage | ProblemRecoveryStage | DopplStage;
type GrowthStage = ProblemRecoveryStage['stage'] | DopplStage['stage'];
type NextOf<S extends Stage> = Extract<StageContract, { stage: S }>['next'];The invariants that fall out of this: no doppl without a recovered problem; no problem without a case study; a problem may produce many doppls, each its own node.
Frontmatter is a discriminated union on stage. The seed has no scores, no lineage, no dedup signal — it is a start, not a claim. Growth-stage nodes carry lineage, the judge score, the materialised human projection, and doppelgangers.
type Uuid = string; // UUIDv4; the durable link key
type NonEmptyArray<T> = [T, ...T[]];
type BaseFrontmatter<S extends Stage> = { id: Uuid; stage: S; next: NextOf<S> };
type CaseStudyFrontmatter = BaseFrontmatter<'case_study'> & { name: string };
type BaseGrowthFrontmatter<S extends GrowthStage> = BaseFrontmatter<S> & {
root: Uuid;
prev: NonEmptyArray<Uuid>;
kernel?: KernelName;
temporal: boolean;
scores: Scores; // judge + materialised human projection
doppelgangers: number;
};
type NodeFrontmatter =
| CaseStudyFrontmatter
| BaseGrowthFrontmatter<'problem_recovery'>
| BaseGrowthFrontmatter<'doppl'>;Links point at
id, never the headline. Names and headlines change freely; the UUID is the stable anchor. This single rule is what makes the whole thing safe to render into a graph database or an Obsidian vault without breaking links when text is edited.
| Section | Accretes? | Meaning |
|---|---|---|
## Trace |
yes | One ### <Stage> · synopsis per completed prior stage, copied verbatim — never reworded or merged. Only the synopsis travels; full thinking stays home. |
## Discovery |
yes | What discovery found (web + stock), citing the stock field it read from or wrote to. Found, not concluded. Not scored. |
## Growth |
replaced per node | The current stage at full fidelity — what it concluded. The only section the judge rates. Holds ### Evaluation. |
## Path |
— | Names the next stage, or null at a doppl. Intentionally duplicates frontmatter next so a reader never has to inspect YAML. |
The headline (# …) is the one-line Growth result; on fold it becomes the next node's Trace synopsis.
MarkScript is the framework that makes the node files both prose and data. It is worth treating as a first-class language because it is the load-bearing idea: it is what lets a folder of notes act like a typed graph database.
Every MarkScript section has exactly three layers:
- meaning — what the section is for (prose);
- markdown shape — what the rendered artifact looks like;
- TypeScript contract — what a parser or validator must recover.
The TypeScript does not replace the markdown. It states what must survive parsing. The two structural primitives are tiny:
type MarkdownSection<Heading extends string, Body> = { heading: Heading; body: Body };
type MarkdownSubsection<Heading extends string, Body> = MarkdownSection<Heading, Body>;
type MarkdownFile<Frontmatter, Body> = { frontmatter: Frontmatter; body: Body };| Rule | What it means |
|---|---|
| Build down | Put primitives first, then base forms, then concrete variants, then the final union at the bottom. The reader should feel the thing being assembled — never open on a negation like NonSeedBody. |
| Information vs definition | Every sentence earns its place as either a definition (names a thing) or information (explains behaviour). History — retired terms, rejected approaches — is neither, and is banned. |
| Type discipline | If a type doesn't constrain, connect, or name a real parsed shape, delete it. never is allowed only when it does real work, never as a gravestone. |
| Source shape matters | Prose uses soft wrap — one paragraph is one source line. Hard-wrapped prose makes the artifact worse. |
| Ownership | One concept, one owner. rating.md owns Rating; node.md references it instead of restating it. Duplication is debt to collapse. |
A MarkScript section works when three readers can use it without the rest of the conversation:
flowchart LR
MD["MarkScript section"] --> H["👤 Human<br/>reads the prose,<br/>understands the artifact"]
MD --> P["⚙️ Parser<br/>finds required headings<br/>and payloads"]
MD --> V["✅ Validator<br/>rejects drift<br/>without interpreting vibes"]
If it only serves one reader, it is not MarkScript yet. This triple is the elevator pitch made testable.
The engine is the distilled contract of the kernel source (src/generate.ts, fitness.ts, select.ts, lens.ts, trace.ts). One spine arrow = one pass of the crucible over a population. It does not pick the best candidate; it breeds a stronger child, and the bar is anti-fragility — a child that gets stronger under variation.
sequenceDiagram
participant Seed as Seed / parents
participant Gen as generate
participant Fit as fitness
participant Sel as select
participant Lens as lens
participant Tr as trace / compile
Seed->>Gen: SeedFixture (operators + source packets)
Note over Gen: apply reproduction operator,<br/>attach lineage + delta,<br/>reject no-delta rehash
Gen->>Fit: CandidatePool
Note over Fit: novelty (0–1) + grounding (0–1)<br/>kept SEPARATE
Fit->>Sel: ScoredCandidatePool
Note over Sel: Pareto front → floor gate →<br/>directional score → keep top 3<br/>(+ regret sibling on the other dial)
Sel->>Lens: SelectionComparison
Note over Lens: feasibility, AFTER selection,<br/>never touches fitness
Lens->>Tr: LensResult[]
Note over Tr: RunTrace = the specimen —<br/>compiler projects survivor → node
A candidate is bred by a named reproduction operator applied to a source packet. Each child carries lineage (parent, generation, operatorId) and an explicit delta: what changed besides wording. Packets with no delta are rejected before scoring — rehash never reaches fitness. (This is invariant #11: every child must state its delta.)
The operators come from mutagen skills — and this is the seam where your AI harness plugs in (Section 13). The kernel records which operator produced each child but never requires a specific external loader.
type CandidateDelta = { summary: string; changes: string[] };
type Candidate = {
id: string;
parentId: string;
parent: { kind: 'seed' | 'candidate'; id: string };
generation: number;
operatorId: string; // e.g. 'polymath', 'first-principles', 'breakout'
temporal: boolean;
title: string;
thesis: string;
substrate: string;
mechanism: string;
delta: CandidateDelta; // rejected before scoring if empty
claims: string[];
evidence: string[];
};Both axes are weighted sums of 0–1 components. They are deterministic text/source signals on purpose: novelty must point at absence-from-record (not "the model says it's novel"), and grounding must point outside the prose.
Novelty = 0.50·sourceAbsence + 0.30·substrateDistance + 0.20·hiddenDependents
Grounding = 0.40·signalStrength + 0.25·mechanismClarity + 0.25·falsifiability − 0.10·riskPenalty
type FitnessScore = {
novelty: number; // 0–1, rounded to 3 dp, clamped
grounding: number; // 0–1
decay: { factor: number; halfLifeDays: number; ageDays: number; temporalBasis: boolean };
components: {
sourceAbsence: number; substrateDistance: number; hiddenDependents: number;
signalStrength: number; mechanismClarity: number; falsifiability: number; riskPenalty: number;
};
reasons: { novelty: string; grounding: string; decay: string };
};Per dial, a SelectionSchedule of { keep, priorityAxis, floorAxis, floor }:
| dial | priority | floor axis | floor |
|---|---|---|---|
| diverge | novelty | grounding | 0.35 |
| converge | grounding | novelty | 0.25 |
The procedure preserves the two-axis tension before any scalar collapse:
- Pareto fronts — rank by non-domination over (novelty × grounding); front 1 is the frontier nothing beats on both.
- Floor gate — drop anything below the floor on the floor-axis.
- Directional score =
priority·0.7 + secondary·0.2 + balanceBonus·0.1, wherebalanceBonus = 1 − |novelty − grounding|. - Decay-adjust — currently a no-op (
decayFactor = 1). - Rank by front, then score, then priority axis; keep top 3.
The regret sibling. Every run computes both dials on the same scored pool and emits a cross-dial contrast per survivor — stable, replaced, or dropped. This is the proof the dial actually changes the run; a no-swap result is allowed data, not a failure.
One pass emits an ordered machine trace — generate → fitness → select → lens — each step naming inputs, decision, and goal-checks. Every human surface (node, board, viewer) is a projection of that trace. The boundary contracts are explicit and typed:
type BoundaryContract = {
module: string;
entersFrom: SpaceRef; input: ContractRef;
output: ContractRef; exitsTo: SpaceRef;
};
// generate → fitness → select → lens → trace → ProofBoardThis is invariant #16 (trace first, views separate) and #19 (every boundary has a contract) — and it is what makes the system auditable rather than a black box.
There are exactly two numeric scales, and conflating them is the classic mistake.
type Measurement = number; // 0…1; an instrument reading. No judgment.
type Rating = -5|-4|-3|-2|-1|0|1|2|3|4|5; // a judgment of worth.Measurement is a reading (0.7 is just 0.7). Rating is worth — and crucially, negative does not mean "it doesn't work"; it means "even if it works, it is bad" (misleading, value-subtracting). 0 is neutral.
A 0…1 measurement detects presence, so it maps only into the positive band:
rating = round(measurement * 5); // 0 → 0, 0.5 → +3, 1 → +5A measurement can never produce a negative rating. Negative ratings are judge-only — a human-or-model judgment that the idea subtracts value.
flowchart LR
M["Measurements 0–1<br/>novelty · grounding · falsifiability"] -->|round of m×5| RB["Bridged ratings<br/>(positive band only)"]
J["Judge (5 axes, −5…+5)<br/>+ full reasoning"] --> SJ["scores.judge<br/>= round(mean(axes))"]
RB --> J
JO["Judge-only axes<br/>Cost-efficiency · Relevance"] --> J
H["Human: ONE slider −5…+5"] --> LED["human ratings ledger<br/>(node_id, rater_id)"]
LED -->|project: mean, n| HP["scores.human · scores.n"]
SJ --> NODE["node frontmatter<br/>scores: {judge, human, n}"]
HP --> NODE
| Axis | Reads | Bridge |
|---|---|---|
| Novelty | reaches something not already covered | round(novelty × 5) |
| Grounding | lands on something true / testable | round(grounding × 5) |
| Falsifiability | states what would make it wrong | round(falsifiability × 5) |
| Cost-efficiency | value vs. all-in ownership cost | judge-only |
| Relevance | matters for the current actor | judge-only |
The judge fills one #### <Axis> <±N> subsection per axis with reasoning; scores.judge is the boil-down. Humans never see this five-axis form — they get one slider. Asking a human to fill five axes will not happen in practice, so the system does not pretend it will.
Human ratings are the source of record and live in a separate ledger, upserted one current rating per (node_id, rater_id) (email for the demo). A later vote from the same email replaces the prior one — it does not add a second vote.
type HumanRating = { rater_id: string; score: Rating; rate_date: string /*ISO 8601*/ };
type HumanNodeRatings = { node_id: Uuid; ratings: HumanRating[] };
type HumanScoresProjection = {
human: number | null; // null when n === 0; else mean(scores), 1 decimal
n: number;
};The node stores only the materialised projection. A projection job (a local command, a scheduled job, a GitHub Action, or a service — intentionally open) reads HumanNodeRatings, computes the projection, and patches scores.human / scores.n back into frontmatter. A born node is judge-only: scores: { judge: 3, human: null, n: 0 }.
temporal is the judge-set boolean seam. true = timing-bound and eligible for future decay; false = timeless. Decay is stubbed to 0, so the effective multiplier is 1 and ratings do not change with age — but a future time mechanism can bolt on without changing the node shape.
Discovery is a round trip that problem_recovery and doppl call (never the case study — a seed doesn't search). On a call it reads stock for what's already known, reaches outward through a backend only when needed, keeps only what clears the bar, writes genuine new finds back to stock, and returns context to the calling stage. It is a modular interface — one verb (discover), pluggable backends (web search now, deep-research later).
Discovery is what was found; Growth is what was concluded.
flowchart LR
Stage["problem_recovery / doppl"] -->|discover| D{Discovery}
D -->|read first| Stock[(Stock field<br/>durable domain memory)]
D -->|reach out when needed| Web[(Web / backend)]
Web --> Adm{Admission gate<br/>novelty + grounding?}
Adm -->|reject| Screened["Screened finds<br/>(audited, not stored as stock)"]
Adm -->|admit| Enr{Enrichment gate}
Enr -->|add| Stock
Enr -->|merge| Stock
Enr -->|drop rehash| X[discard]
Stock -->|context| Stage
- Admission decides whether a find is worth remembering (requires both novelty and grounding signal). Low-value finds are screened — counted and auditable, but not stored as stock.
- Enrichment decides what happens to an admitted find: add (new), merge (strengthen an existing discovery), or drop (rehash).
type StockSignal = { novelty: Measurement; grounding: Measurement };
type AdmittedDiscovery = {
id: Uuid; field: FieldRef; claim: string; keywords: string[];
sources: NonEmptyArray<SourceRef>; signal: StockSignal;
created: string; updated: string; // ISO 8601
};Stock is not raw search output and not Growth. Discovery finds; stock remembers; stages conclude. The rendered stock field is a projection grouped by field, with discoveries and finds_screened counts in frontmatter — the human-readable "load-bearing facts" of a domain.
Doppl is not the first system to put LLM output under evolutionary selection — and knowing the neighbours sharpens what is genuinely distinctive here.
| System | What it does | Relationship to Doppl |
|---|---|---|
| AlphaEvolve (Google DeepMind) | An evolutionary coding agent: an LLM ensemble proposes program mutations, automated evaluators verify them by execution, and an evolutionary loop keeps the best over many generations. It broke a 56-year matrix-multiplication record and improved Google's own infrastructure. | Same skeleton — generate → evaluate → select → repeat. The hard difference: AlphaEvolve needs a programmatic ground-truth evaluator (you must be able to run and score the answer). Doppl's whole bet is the domains where no cheap ground truth exists ("is this a good idea?"), so it manufactures fitness from adversarial critics + two-axis measurement instead. |
| FunSearch / Evolution of Heuristics | Earlier work pairing LLMs with evolutionary search to beat human baselines on math/heuristics. | Proved the LLM-as-mutation-operator synergy Doppl inherits. |
| CodeEvolve (open source) | Operationalises AlphaEvolve-style search openly using the island genetic algorithm: multiple populations evolve independently and periodically migrate their best individuals. | This is your "islands of graphs" intuition, formalised. Doppl's islands are case-study-rooted; migration is the leaf→reseed and convergence edges. |
| MAP-Elites / quality-diversity | Search that fills a grid of "niches" to keep a diverse set of high-performers rather than one optimum. | Doppl's anti–mode-collapse pressure (novelty floor, Pareto fronts, sprouts) is the same instinct, expressed through the dial. |
| Obsidian / Zettelkasten + graph PKM | Linked plain-markdown notes with a graph view; mature ecosystem of Neo4j bridges and MCP servers. | This is Doppl's natural substrate and cockpit (Section 12) — but PKM has no engine. Doppl adds the selection loop on top of the vault. |
- Fitness without ground truth. AlphaEvolve climbs an executable score; Doppl climbs a manufactured one (two-axis measurement + adversarial critics + held-out human judgment as the un-fakeable anchor).
- The artifact is the medium. Most evolutionary systems store candidates as opaque rows in a programs database. Doppl's candidates are human-readable markdown under a typed contract — readable, linkable, gradable, and re-seedable by hand.
- One kernel, three dial settings. Discovery / problem-recovery / solution-finding are explicitly the same engine, not three pipelines — a strong unifying claim most stacks don't make.
- The trace is the truth, the node is a projection. Auditable by construction; the human surface can never silently diverge from what the machine actually did.
The design goal is local-first, harness-agnostic, and substrate-portable. The same node files should work whether you open them in a text editor, an Obsidian vault, or a Neo4j browser. Below are four substrates, a decision matrix, and a recommended hybrid.
| # | Substrate | What it is | Best at | Weak at |
|---|---|---|---|---|
| A | Git + Markdown (plain) | The node files in a repo; PRs are folds; history is lineage. | Source of truth, diffability, zero lock-in, CI hooks (the projection job as a GitHub Action). | No native graph view; querying "all doppls with judge ≥ +3 in island X" needs a script. |
| B | Obsidian vault | The same repo opened in Obsidian; [[wikilinks]] + frontmatter; built-in graph view; Dataview queries. |
The human cockpit — read, link, hand-prune sprouts, eyeball islands. Plugins for Neo4j, Mermaid, Dataview. | Graph view is presentation, not a real query engine; large vaults get noisy. |
| C | Local Neo4j (or FalkorDB) | A derived property graph: nodes = Doppl nodes, edges = prev/root/convergence; Cypher for queries. |
Real graph queries & visualisation — convergence detection, lineage walks, "islands", Bloom visuals. | A projection, not the truth; needs a sync job; another service to run. |
| D | Hybrid (recommended) | Git/markdown is canon; a parser projects into Neo4j; Obsidian is the editor; an MCP server exposes the graph to your agent. | All of the above, with a single source of truth. | Most moving parts (mitigated because the graph and vault are derived and disposable). |
Git/markdown is the source of truth. Everything else is a disposable projection of it — which is exactly the system's own "trace is the specimen, views are projections" rule applied to the deployment.
flowchart TB
subgraph CANON["Source of truth (canon)"]
REPO[("Git repo<br/>nodes/*.md · stock/*.md<br/>specs/*.md (MarkScript contracts)")]
LEDGER[("human-ratings ledger<br/>(JSON / table)")]
end
subgraph ENGINE["Kernel (TypeScript, local)"]
KER["buildRunTrace()<br/>generate → fitness → select → lens"]
COMP["compiler<br/>RunTrace → node.md"]
PROJ["projection job<br/>ledger → scores.human / n"]
end
subgraph HARNESS["AI harness (yours)"]
AG["Claude Code / Cursor<br/>drives the operators"]
SK["mutagen skills<br/>polymath · first-principles · breakout …"]
RULES["rules / invariants<br/>(CLAUDE.md · AGENTS.md · INVARIANTS.md)"]
end
subgraph VIEWS["Projections (disposable)"]
OBS["Obsidian vault<br/>(human cockpit)"]
NEO[("Neo4j / FalkorDB<br/>graph queries + Bloom")]
BOARD["proof board<br/>(one-glance run summary)"]
end
AG <--> SK
AG <--> RULES
AG --> KER
KER --> COMP --> REPO
LEDGER --> PROJ --> REPO
REPO -->|open directly| OBS
REPO -->|parse + load| NEO
KER --> BOARD
NEO <-->|MCP server, read-only| AG
OBS -.->|edit / hand-prune| REPO
- Markdown canon honours invariant #23 (no silent source-of-truth split): the files say what they own; the graph and vault declare themselves projections.
- Neo4j is derived, so a corrupted or stale graph is never a data-loss event — you rebuild it from the repo. The Obsidian→Neo4j bridge pattern (typed links
- linkType [[note]], frontmatter → node properties) is well-trodden, and modern setups expose the graph to an agent over MCP, read-only, so the agent can query lineage and convergence without write access to your truth. - The proof board is the cheap, one-glance run summary:
seed → generated → rejected → Explore keeps → Proof keeps → swap → failed checks. It is the first thing you look at; nodes are the drill-down.
The graph projection earns its keep the moment you ask graph-shaped questions:
// Convergence: distinct islands arriving at the same attractor (a DERIVED query, never stored)
MATCH (a:Doppl)-[:ROOT]->(ra), (b:Doppl)-[:ROOT]->(rb)
WHERE ra <> rb AND a.embeddingCluster = b.embeddingCluster
RETURN a, b, a.embeddingCluster AS attractor;
// Process-health: doppelgangers piling up on low-rated ideas (the generator is stuck)
MATCH (n:Node)
WHERE n.doppelgangers >= 5 AND n.judge <= 0
RETURN n.id, n.headline, n.doppelgangers, n.judge ORDER BY n.doppelgangers DESC;
// The best leaves to reseed
MATCH (d:Doppl) WHERE d.judge >= 3 AND coalesce(d.human, d.judge) >= 3
RETURN d.id, d.headline, d.judge, d.human ORDER BY d.judge DESC LIMIT 10;This is the part that makes Doppl runnable today, on your own machine, with the agent you already use. The repo already proves the pattern: it ships .cursor/skills/, a CLAUDE.md, an AGENTS.md, and INVARIANTS.md.
Each reproduction operator is a mutagen skill — a SKILL.md with a persona, a mechanism, and a lineage block. The engine records which operator bred a child but never hard-codes a loader, so the same operator definitions work across harnesses.
rule-of-cool / breakthrough gen-0 seed: converge on the best move within the frame
├── breakout valence-flip up: drop the feasibility filter, hunt the frame-breaker
├── blindside valence-flip down: hunt the buried failure mode / opportunity cost
├── addition-by-subtraction best-cut instead of best-add: highest-leverage removal
├── first-principles basis-transform: strip to invariants, rebuild
├── constraint-injection scarcity operator: add the productive constraint
└── polymath domain-transfer: import a proven mechanism from another field (the Medici Effect)
polymath is the operator that most directly serves Doppl's two prey (cross-domain transfer, zeitgeist synthesis): it abstracts a problem to its structural essence, scans distant domains for where that structure is already solved, and ranks transplants by structural fit × domain distance — far enough to surprise, close enough to work.
The harness reads the invariants as guardrails — the rules that must survive any implementation change (finite by construction; novelty cannot be pure model self-grading; grounding must point outside the prose; append, do not overwrite; every child states its delta; trace first, views separate; every boundary has a contract). These map cleanly onto a Cursor .mdc rule file or a Claude Code project rule.
sequenceDiagram
actor U as You
participant H as Harness (Claude Code / Cursor)
participant S as Skills (operators)
participant K as Kernel (TS)
participant R as Repo (canon)
participant V as Views (Obsidian / Neo4j)
U->>H: seed a case_study (a situation / postulation)
H->>R: write case_study node (no scores)
U->>H: "recover the problem"
H->>S: invoke operators (first-principles, polymath, …)
S-->>H: candidate problem-frames (each with a delta)
H->>K: score + select (converge: grounding priority)
K-->>H: survivors + RunTrace + regret siblings
H->>R: compile problem_recovery node(s)
U->>H: "grow the doppls"
H->>S: invoke operators (diverge: novelty priority)
S-->>H: candidate unlocks
H->>K: score + select + lens
K-->>R: compile doppl leaves
R->>V: project to vault + graph
U->>V: read board, hand-prune sprouts, rate (one slider)
U-->>R: reseed best leaf as a new case_study (close the loop)
The human stays in three spots only: seeding, one-slider rating, and hand-pruning sprouts / choosing which leaf to reseed. Everything else is the harness driving the kernel through the operators.
Conceptual strengths
- A real unifying claim. "One engine, three dial settings, fitness that flips with the dial" is genuinely elegant and rare: it collapses discovery, problem-finding, and solution-finding into one auditable loop.
- It names the failure modes and designs for them. Confident slop and mode collapse are not afterthoughts; the two-axis fitness, the floors, and the regret sibling exist specifically to keep both visible.
- The artifact is the medium. Because every step is human-readable markdown under a typed contract, the system is legible at every stage — you can read why a node won, not just that it won.
- Provenance and anti-fragility are first-class. Every child states its delta; lineage is append-only; the trace is the specimen. This is closer to a lab notebook than a black box.
Practical strengths
- Harness-agnostic and local-first. No bespoke runtime: your existing coding agent supplies the intelligence; the kernel supplies control, scoring, and caps.
- Portable substrate. The same files are a Git repo, an Obsidian vault, and a Neo4j projection — pick the view per task.
- Finite by construction. Hard caps on generations, children, population (and, later, budget) make runaway recursion a configuration choice, not a risk.
- De-risked seed. The generation-0 organism already exists as a working skill (the Rule of Cool / breakthrough progenitor); the work is mutating it into a population, not inventing from zero.
Stated plainly, because the design's own ethos is "every proof needs a tripwire."
| Risk | Why it bites | Mitigation already in the design |
|---|---|---|
| Fitness without ground truth (the core bet) | If the critics/measurements are weak, evolution optimises for fooling the judge, not for good ideas. | Ground critics in retrieval + executable checks where possible; hold human judgment out of the breeding loop as the un-fakeable anchor; rotate critic operators so the target keeps moving. The objective can evolve; its anchor cannot. |
| Mode collapse / slop convergence | Populations love to converge on one safe, mediocre genome. | Novelty floor, Pareto fronts, sprouts, and cross-distance fusion are explicit anti-collapse forces. |
| Deterministic measurements are shallow | Token-overlap "novelty" can be gamed by surface wording; it is admittedly a placeholder. | The contract keeps score detail rich enough to swap in embedding-cosine / cluster-coverage / DPP novelty when a named consumer needs it — without rewriting the corpus. |
| The second axis may be wrong | Novelty × grounding may be less true than truth × consensus-gap (a claim can be true, novel, grounded — yet already priced by every serious observer). | Flagged as an open question; the corpus is built to test it later. |
| Cost / termination | Recursive spawning can blow a budget combinatorially. | Structural caps today; budgeted energy caps (tokens, tool calls, wall-clock, money) are the next layer — a lineage that overruns dies or pauses, never borrows invisible compute. |
| Projection drift | The Neo4j graph or Obsidian view can fall out of sync with the markdown canon. | They are derived and disposable; a full recompute rebuilds them from the repo. The ledger projection job can run as a CI action to keep scores fresh. |
| Human-rating thinness | One slider per (node, rater) is easy to gather but low-resolution, and small n makes human noisy. |
Intentional: five axes from humans won't happen in practice. Treat human as a coarse anchor, not a precise score; lean on n before trusting it. |
| Two-week-realism (capstone framing) | The moonshot (self-evolving verifier, in-house fine-tuning flywheel) may not converge in the timebox. | The single-generation fusion cut is the guaranteed-to-run MVP; the flywheel is explicitly stretch. |
flowchart LR
P0["Phase 0<br/>Canon + contracts<br/>(specs/*.md, node template,<br/>case_study seeds)"]
P1["Phase 1<br/>Single-generation kernel<br/>generate→fitness→select→lens<br/>on fixtures, proof board"]
P2["Phase 2<br/>Compiler → node.md<br/>+ Obsidian vault view"]
P3["Phase 3<br/>Graph projection (Neo4j)<br/>+ convergence / island queries"]
P4["Phase 4<br/>Harness loop<br/>(skills as operators, rules,<br/>human one-slider + reseed)"]
P5["Phase 5 (stretch)<br/>budget caps · richer novelty ·<br/>self-evolving critics · fine-tune flywheel"]
P0 --> P1 --> P2 --> P3 --> P4 --> P5
- Phase 0–1 is the SPEC's "definition of done": the kernel runs end-to-end on at least one real seed, produces a lineage tree + scored survivors a human can judge, and the same-seed diverge-vs-converge contrast is demonstrable.
- Recursion is earned (invariant #5): don't deepen past depth-1 until a shallow run produces judgeable output and shows what to breed next.
- Phase 3 onward is where the "islands of graphs" become queryable and visual, and where reseeding leaves closes the forest loop.
| Term | Meaning |
|---|---|
| Kernel | The reusable loop generate → evaluate → select → generate again, parameterised by direction, reproduction unit, fitness, and schedule. |
| Dial / direction | The diverge ↔ converge knob; an oscillating schedule alternates them. The schedule is the application. |
| Node | One markdown file = one step of an idea's journey; a projection of the RunTrace. |
| Stage | case_study → problem_recovery → doppl. Each arrow is one crucible pass. |
| doppl | The amorphous leaf: the unlock / solution / idea. |
| Pepsi | The metaphor for one-vs-many doppls (the perfect Pepsi vs. the perfect Pepsis). Not a schema term. |
| Measurement (0–1) | An instrument reading. No judgment. Maps into ratings. |
| Rating (−5…+5) | A judgment of worth. Negative = value-subtracting, not merely ineffective. |
| Novelty / Grounding | The two warring fitness axes; kept separate until selection. |
| Decay / temporal | The time axis. temporal boolean is live; decay is stubbed to 0. |
| Lens | Observer-relative feasibility, scored after selection; never contaminates fitness. |
| Doppelgangers | Stored count of near-duplicates deduped into a node — the one fact dedup destroys. |
| Convergence | Distinct lineages hitting the same target. Derived, never stored. |
| Sprout | A rare high-novelty side-idea kept on the node for later; pruned by hand. |
| Bedrock | An anchor the generator cannot move: executable check, held-out case, dated prediction, human judgment, replayable run. |
| Stock | Durable domain memory: admitted discoveries, not raw search output and not Growth. |
| Island / forest | One case-study tree is an island; reseeded leaves + convergence edges make the forest. |
| Contract | Owns | File |
|---|---|---|
| Node | File shape, stages, frontmatter union, the four body sections, headline, portable synopsis, doppelgangers |
specs/node.md |
| Rating | Rating, Measurement, the bridge, the five judge axes, EvaluationSection, temporal policy |
specs/rating.md |
| Human ratings ledger | HumanRating, upsert semantics, HumanScoresProjection, materialisation |
specs/human-ratings-ledger.md |
| Stock | Fields, admitted discoveries, screened finds, admission + enrichment gates, rendered field | specs/stock.md |
| MarkScript | The three-layer idiom, build-down, type discipline, ownership rules | my-docs/garden/markscript.md |
| Engine / SPINE / INVARIANTS | The crucible, the dial, the two non-negotiables, the kernel invariants | my-docs/garden/engine.md, SPINE.md, INVARIANTS.md |
| Kernel runtime | buildRunTrace(), boundary contracts, the typed pipeline |
src/contracts/index.ts, src/trace.ts |
The single rule that ties the whole document together: the markdown is the authored artifact; the TypeScript names what must be recoverable from it; and every human view — vault, graph, board — is a disposable projection of one machine trace that is the real specimen.