diff --git a/Dockerfile b/Dockerfile index 4f91ae7..b545c91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,8 +23,9 @@ RUN npm ci --omit=dev # Copy server code COPY server/ ./server/ -# Copy shared constants for server use +# Copy shared modules the server imports from src/ COPY src/utils/constants.js ./src/utils/constants.js +COPY src/utils/knowledgeGraph.js ./src/utils/knowledgeGraph.js # Copy built frontend COPY --from=builder /app/dist ./dist diff --git a/README.md b/README.md index 443c42c..51233d1 100644 --- a/README.md +++ b/README.md @@ -23,16 +23,28 @@ Toolbox is a self-hosted web app that brings together everything you need to pre | ๐ **Knowledge Guide** | 7-pillar structured library with AI-assisted Commit flow to save learnings from chat sessions | | ๐จ **Architecture Builder** | Drag-and-drop whiteboard with 20+ components, bezier connections, templates, and AI design verification | | ๐ **Flashcards + SRS** | SM-2 spaced repetition system with per-deck settings, card browser, deck stats, and a study activity heatmap | +| ๐ธ๏ธ **Knowledge Graph** | Interactive prerequisite map of ~60 system design concepts with a live SM-2 retention heatmap, readiness filters, curated learning tracks, and deep links into Guide, Builder, and Flashcards | +| ๐งฑ **Adaptive Remediation** | Fail an advanced card and the SRS engine detects shaky foundations, then queues the prerequisite cards into your next review session | +| ๐งฎ **BotE Calculator** | Back-of-the-envelope sizing sandbox: live QPS/storage/cache/bandwidth/hardware math, scenario presets, an interactive latency cheat-sheet with a budget composer, AI "Audit My Math", and 1-click Markdown export | | ๐ง **Feynman Simulator** | Voice-enabled Feynman technique: explain a concept, get structured AI feedback on gaps | | ๐ **Interleaved Review** | Study all due cards across every deck in a single shuffled session | | ๐ **Pomodoro Timer** | Persistent focus timer with plant gamification (๐ฑโ๐ธโ๐ฅ) and Strict Mode | | โ๏ธ **Shadow Memory** | The AI learns your timeline, strengths, and goals across sessions for personalized coaching | +| ๐ **Multi-Provider AI** | Gemini, Claude, and OpenAI out of the box, plus bring-your-own-model via any OpenAI-compatible endpoint (Ollama, LM Studio, vLLM, OpenRouter, โฆ) with live model discovery | --- ## ๐ธ Screenshots
| Knowledge Graph โ prerequisite map with live SRS heatmap | +BotE Calculator โ capacity estimation sandbox | +||||||||
![]() |
+ ![]() |
+ ||||||||
| Architecture Whiteboard Builder | Structured Knowledge Guide | @@ -111,7 +123,7 @@ All user data lives in a single SQLite file โ back it up by copying that file. ## ๐ ๏ธ Tech Stack -React 19 ยท Vite ยท React Router ยท Zustand ยท Vanilla CSS ยท Node.js ยท Express ยท SQLite (`better-sqlite3`) ยท Vercel AI SDK (Gemini / Claude / OpenAI / BYOM) ยท Docker +React 19 ยท Vite ยท React Router ยท Zustand ยท Vanilla CSS ยท d3-force ยท Node.js ยท Express ยท SQLite (`better-sqlite3`) ยท Vercel AI SDK (Gemini / Claude / OpenAI / BYOM) ยท Docker --- diff --git a/docs/AGENTS.md b/docs/AGENTS.md index a8094ad..38fa975 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -55,10 +55,11 @@ import { FaArrowRight } from 'react-icons/fa' ### 3. Global State: Use Existing Zustand Stores For features requiring cross-page persistence, use the existing stores: -- `src/stores/appStore.js` โ Theme, sidebar, chat panels, API key, whiteboard nodes/edges, toasts +- `src/stores/appStore.js` โ Theme, sidebar, chat panels, API key, whiteboard nodes/edges, toasts, calculator modal, `srsVersion` sync counter - `src/stores/useTimerStore.js` โ Pomodoro timer state +- `src/stores/useCalcStore.js` โ BotE Calculator inputs, scenario, and latency budget (persisted to localStorage) -Do not create new top-level stores unless the feature genuinely requires it and doesn't fit in either existing store. +Do not create new top-level stores unless the feature genuinely requires it and doesn't fit in an existing store. ### 4. Backend Changes: Update Both db.js and Routes @@ -145,9 +146,16 @@ server/ | Flashcards + SRS | `/study` | `StudyPage.jsx`, `FlashcardView.jsx`, `DeckEditor.jsx`, `CardBrowser.jsx`, `StatsDashboard.jsx` | | Feynman Simulator | `/feynman` | `FeynmanPage.jsx` | | Interleaved Review | `/interleaved` | `InterleavedPage.jsx` | +| Knowledge Graph | `/graph` | `GraphPage.jsx`, `components/graph/*`, `src/utils/knowledgeGraph.js`, `server/routes/graph.js`, `server/graph/*` | +| BotE Calculator | `/calculator` + global `โE` modal | `CalculatorPage.jsx`, `components/calculator/*`, `src/utils/bote.js`, `useCalcStore.js`, `server/routes/calculator.js` | | Pomodoro Timer | Global modal | `PomodoroModal.jsx`, `PomodoroWidget.jsx`, `useTimerStore.js` | | Settings | `/settings` | `SettingsPage.jsx` | +Cross-cutting modules: +- `src/utils/knowledgeGraph.js` โ pure graph data + algorithms, imported by BOTH client and server. Keep it dependency-free. Edges must stay acyclic โ `validateGraph()` runs at server boot and in tests. +- `server/srs/scheduler.js` โ the SM-2 scheduler (`calculateNextSrsState`), shared by decks and graph routes. +- `server/graph/health.js` / `server/graph/remediation.js` โ pure functions; the route layer owns all DB access. + --- ## Data Structure: Pillars & Blueprint Sections diff --git a/docs/api.md b/docs/api.md index a8a2873..0d921d2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12,6 +12,8 @@ All request bodies are JSON (`Content-Type: application/json`) unless noted. All - [Chat & AI](#chat--ai) - [Flashcard Decks](#flashcard-decks) - [Flashcards](#flashcards) +- [Knowledge Graph](#knowledge-graph) +- [BotE Calculator](#bote-calculator) - [Whiteboard Boards](#whiteboard-boards) - [Guide Content](#guide-content) - [User Profile (Shadow Memory)](#user-profile-shadow-memory) @@ -465,7 +467,7 @@ All fields are optional. SRS state fields (`ease_factor`, `interval`, etc.) are ### `PUT /api/decks/:deckId/cards/:cardId/review` -Submit a review rating for a card. Runs the SM-2 SRS algorithm and persists the next state. Also increments the study session heatmap counter for today. +Submit a review rating for a card. Runs the SM-2 SRS algorithm and persists the next state. Also increments the study session heatmap counter for today. (`POST` is accepted as an alias for older clients and queued offline mutations.) **Request Body** ```json @@ -482,6 +484,27 @@ Submit a review rating for a card. Runs the SM-2 SRS algorithm and persists the **Response `200`** โ Updated card object with recalculated SRS fields and fresh `srs_previews`. +Side effects tied to the Knowledge Graph remediation engine: + +- Reviewing a card resolves its open `remediation_queue` rows. +- On failure (`quality < 3`), the engine finds the card's graph nodes, checks their direct prerequisite nodes for shaky cards (new, learning, lapsed, overdue, or low ease), and queues up to 3 of them for the next session. The response includes the plan: + +```json +{ + "...": "updated card fields", + "remediation": [ + { + "cardId": "uuid", + "front": "What is replication lag?", + "nodeName": "Replication (Leader / Follower)", + "reason": "Foundation for CAP & PACELC" + } + ] +} +``` + +Queued cards lead the next due-card session (single-deck and interleaved) tagged with `is_remediation: 1`, `remediation_node`, and `remediation_reason`. + --- ### `DELETE /api/decks/:deckId/cards/:cardId` @@ -492,6 +515,104 @@ Delete a single card. --- +## Knowledge Graph + +The graph itself (nodes, prerequisite edges, learning tracks) is defined in `src/utils/knowledgeGraph.js` and validated (unique ids, no dangling edges, acyclic) at server boot. These endpoints combine it with live flashcard SRS state. + +### `GET /api/graph` + +The full graph with live memory health per node. + +**Response `200`** +```json +{ + "nodes": [ + { + "id": "consistent-hashing", + "name": "Consistent Hashing", + "pillarId": "distributed-mechanics", + "pillarName": "Distributed Data Mechanics", + "pillarColor": "#a78bfa", + "topicId": "partitioning-sharding", + "summary": "The hash ring: adding a node only remaps the arc it owns.", + "health": "decayed", + "strength": 0.35, + "counts": { "total": 2, "new": 1, "learning": 0, "lapsed": 1, "due": 0, "maturing": 0, "mastered": 0 }, + "ready": false, + "locked": false + } + ], + "edges": [{ "from": "partitioning", "to": "consistent-hashing" }], + "tracks": [ + { "id": "senior-distributed", "name": "Senior Distributed Systems", "emoji": "๐ง ", "description": "...", "nodeIds": ["..."], "nodeCount": 23, "masteredCount": 2 } + ], + "stats": { "mastered": 4, "due": 20, "decayed": 2, "unseen": 33 }, + "remediationCount": 1 +} +``` + +Health buckets: `mastered` (every graded card at ease โฅ 2.5 and interval > 21 d), `due` (learning, new, or scheduled within 48 h), `decayed` (a lapse, a queued remediation, or crushed ease), `unseen` (no linked cards). Cards link to nodes by keyword phrases, with the card's source guide topic as the fallback. + +--- + +### `GET /api/graph/nodes/:id` + +Detail payload for the slide-over panel: linked cards with SRS state, prerequisite/dependent nodes with health, the linked Guide topic (with filled-section count), and whiteboards containing the node's builder components. + +**Response `200`** โ `{ node, cards, prereqs, dependents, guide, boards }`. + +--- + +### `POST /api/graph/nodes/:id/session` + +Build a study session from one node's studyable cards (due, learning, or new), capped at 20. + +**Response `200`** โ `{ cards, nodeName }`. Cards carry `deckName`, `nodeName`, and `srs_previews`. + +--- + +### `POST /api/graph/tracks/:id/session` + +Build a study session for a curated learning track. Cards are ordered by the track's topological (prerequisite-first) node order, due cards before new within each node, capped at 30. + +**Response `200`** โ `{ cards, trackName, nodeOrder }`. + +--- + +## BotE Calculator + +All sizing math runs client-side in `src/utils/bote.js`. The server's only calculator endpoint is the AI sanity check. + +### `POST /api/calculator/audit` + +"Audit My Math" โ sends the current assumptions and computed results to the AI and returns a structured critique. + +**Request Body** +```json +{ + "scenario": { "id": "url-shortener", "name": "URL Shortener", "description": "..." }, + "inputs": { "dau": 10000000, "readRatio": 100, "...": "..." }, + "results": { "peakTotalQps": "463", "retainedWithReplication": "1.08 TB", "...": "..." }, + "model": "gemini-3.5-flash" +} +``` + +**Response `200`** +```json +{ + "verdict": "revisit", + "summary": "Two or three sentences on the estimate's quality.", + "findings": [ + { "severity": "warning", "area": "Storage", "finding": "...", "suggestion": "..." } + ], + "omittedFactors": ["Database index overhead", "CDN offload of media egress"] +} +``` + +`verdict` is `"sound"` or `"revisit"`. `severity` is `"critical"`, `"warning"`, or `"info"`. + +--- + ## Whiteboard Boards ### `GET /api/boards` diff --git a/docs/architecture.md b/docs/architecture.md index 7e64e05..3d47a9d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -248,9 +248,16 @@ stream: async (payload, onChunk, onDone, signal) => { server/ โโโ index.js # Express app setup, middleware, route mounting โโโ db.js # SQLite connection, schema creation, migrations +โโโ srs/ +โ โโโ scheduler.js # SM-2 scheduling (shared by decks + graph routes) +โโโ graph/ +โ โโโ health.js # Knowledge-graph node health from SRS state (pure) +โ โโโ remediation.js # Prerequisite remediation planning (pure) โโโ routes/ โโโ chat.js # AI chat endpoints (streaming + starters) - โโโ decks.js # Flashcard deck + card CRUD + SRS logic + โโโ decks.js # Flashcard deck + card CRUD + SRS review + remediation queue + โโโ graph.js # Knowledge graph: health, node detail, study sessions + โโโ calculator.js # BotE Calculator AI audit โโโ boards.js # Whiteboard board CRUD โโโ guide_content.js # Guide section content CRUD โโโ config.js # API key management @@ -288,6 +295,8 @@ The full API reference is in **[`docs/api.md`](api.md)**. Quick summary of route | `search.js` | `/api/search` | Cross-domain full-text search | | `study_sessions.js` | `/api/study_sessions` | Study heatmap data | | `system.js` | `/api/system` | Stats, cache flush, DB download | +| `graph.js` | `/api/graph` | Knowledge graph with live SRS health, node detail, track/node study sessions | +| `calculator.js` | `/api/calculator` | "Audit My Math" structured AI critique | --- diff --git a/docs/screenshots/screenshot-builder.png b/docs/screenshots/screenshot-builder.png index af4311a..117ba35 100644 Binary files a/docs/screenshots/screenshot-builder.png and b/docs/screenshots/screenshot-builder.png differ diff --git a/docs/screenshots/screenshot-calculator.png b/docs/screenshots/screenshot-calculator.png new file mode 100644 index 0000000..0157f22 Binary files /dev/null and b/docs/screenshots/screenshot-calculator.png differ diff --git a/docs/screenshots/screenshot-chat.png b/docs/screenshots/screenshot-chat.png index 3eb5680..3929064 100644 Binary files a/docs/screenshots/screenshot-chat.png and b/docs/screenshots/screenshot-chat.png differ diff --git a/docs/screenshots/screenshot-feynman.png b/docs/screenshots/screenshot-feynman.png index fbc5f79..7128950 100644 Binary files a/docs/screenshots/screenshot-feynman.png and b/docs/screenshots/screenshot-feynman.png differ diff --git a/docs/screenshots/screenshot-flashcards.png b/docs/screenshots/screenshot-flashcards.png index 24e4062..248a05e 100644 Binary files a/docs/screenshots/screenshot-flashcards.png and b/docs/screenshots/screenshot-flashcards.png differ diff --git a/docs/screenshots/screenshot-graph.png b/docs/screenshots/screenshot-graph.png new file mode 100644 index 0000000..0b58fec Binary files /dev/null and b/docs/screenshots/screenshot-graph.png differ diff --git a/docs/screenshots/screenshot-guide.png b/docs/screenshots/screenshot-guide.png index 51b1176..21da950 100644 Binary files a/docs/screenshots/screenshot-guide.png and b/docs/screenshots/screenshot-guide.png differ diff --git a/docs/screenshots/screenshot-settings.png b/docs/screenshots/screenshot-settings.png index 29ddb76..d05fa6c 100644 Binary files a/docs/screenshots/screenshot-settings.png and b/docs/screenshots/screenshot-settings.png differ diff --git a/docs/srs-algorithm.md b/docs/srs-algorithm.md index af7d497..80aed4b 100644 --- a/docs/srs-algorithm.md +++ b/docs/srs-algorithm.md @@ -164,6 +164,21 @@ This allows building concept dependency graphs inside a deck. --- +## Knowledge-Graph Remediation + +Beyond per-card `prerequisite_id` links, the Knowledge Graph (`src/utils/knowledgeGraph.js`) defines a concept-level prerequisite DAG. Failing a card (`quality < 3`) triggers the remediation engine (`server/graph/remediation.js`): + +1. Map the failed card to its graph nodes (keyword phrases first, source guide topic as fallback). +2. Collect the **direct** prerequisite nodes (one hop up, never transitive). +3. Among those nodes' cards, keep the **shaky** ones: never studied, in Learning/Relearning, `ease_factor < 2.3`, `interval < 4` days, or overdue. Solid foundations produce no remediation โ then the failure is treated as intrinsic to the card. +4. Queue at most **3** cards (weakest ease first) into `remediation_queue`. + +Queued cards lead the next due-card session (before learning/review/new cards), flagged `is_remediation: 1` so the UI shows a Foundation Checkup banner. Reviewing a queued card โ at any rating โ resolves its queue row. Duplicate open rows for the same card are never created, so the queue cannot flood. + +Node health for the graph heatmap lives in `server/graph/health.js`: a node is **mastered** when every graded card has `ease โฅ 2.5` and `interval > 21` days with nothing due within 48 hours, **decayed** on any lapse/remediation/crushed ease, **due** otherwise, and **unseen** with no linked cards. + +--- + ## SRS Preview Labels The UI shows interval previews for all 4 buttons before the user picks. Formatting rules: @@ -179,7 +194,7 @@ The UI shows interval previews for all 4 buttons before the user picks. Formatti ## Implementation Reference -The full algorithm is a single pure function `calculateNextSrsState(card, quality, settings, confidence)` in [`server/routes/decks.js`](../server/routes/decks.js#L25). It returns a plain object with: +The full algorithm is a single pure function `calculateNextSrsState(card, quality, settings, confidence)` in [`server/srs/scheduler.js`](../server/srs/scheduler.js) (shared by the decks and knowledge-graph routes). It returns a plain object with: ```js { diff --git a/docs/user-guide.md b/docs/user-guide.md index 45cc871..543336a 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -14,9 +14,11 @@ A complete guide to all features of the Toolbox system design interview preparat 6. [Flashcards โ Spaced Repetition](#flashcards--spaced-repetition) 7. [Feynman Simulator](#feynman-simulator) 8. [Interleaved Review](#interleaved-review) -9. [Pomodoro Timer](#pomodoro-timer) -10. [Settings & Configuration](#settings--configuration) -11. [Keyboard Shortcuts](#keyboard-shortcuts) +9. [Knowledge Graph](#knowledge-graph) +10. [BotE Calculator](#bote-calculator) +11. [Pomodoro Timer](#pomodoro-timer) +12. [Settings & Configuration](#settings--configuration) +13. [Keyboard Shortcuts](#keyboard-shortcuts) --- @@ -42,7 +44,9 @@ The left sidebar provides access to all sections: | **Flashcards** | `โ4` | Spaced repetition study decks | | **Feynman** | `โ5` | Feynman Technique simulator | | **Interleaved** | `โ6` | Cross-deck review session | -| **Pomodoro** | `โ7` | Focus timer with plant gamification | +| **Graph** | `โ7` | Knowledge Graph with live retention heatmap | +| **Calculator** | `โ8` | Back-of-the-envelope capacity estimation sandbox | +| **Pomodoro** | `โ9` | Focus timer with plant gamification | | **Settings** | `โ,` | API key, model, and data management | The sidebar can be collapsed with `โB` to maximize your working area. @@ -272,9 +276,74 @@ Access it from the sidebar (`โ6`) or navigate to `/interleaved`. --- +## Knowledge Graph + +The Graph page (`/graph`, `โ7`) turns your flashcards and guide notes into an interactive **prerequisite map** of ~60 system design concepts, grouped by the 7 pillars. An edge means "learn this first" โ for example *Consistent Hashing โ Distributed KV Stores โ Virtual Nodes*. + +### Retention Heatmap + +Node colors track your live SM-2 memory health, updating immediately after every graded card โ no reload needed: + +- ๐ข **Mastered** โ every linked card sits at ease โฅ 2.5 with an interval over 21 days +- ๐ก **Learning / Due** โ something is new, learning, or scheduled within 48 hours +- ๐ด **Decayed / Fragile** โ a card lapsed, has crushed ease, or waits in the remediation queue +- โช **Not started** โ no cards link to this concept yet + +Cards link to concepts automatically: keyword phrases in the card text match first; the card's source guide topic is the fallback. + +### Exploring + +- **Hover** a node to light up its full lineage โ prerequisites in indigo, everything it unlocks in teal. +- **Click** a node to open the slide-over panel: memory strength, linked flashcards with their SRS states, prerequisite and dependent concepts, plus deep links to the Guide topic, related whiteboards, and a one-click **Study N cards** session. +- **Search**, **pillar chips**, and the readiness filter (*Ready to Learn / Needs Review / Mastered*) narrow the view. "Ready to Learn" surfaces untouched concepts whose prerequisites you have already graduated. + +### Learning Tracks + +Pick a curated track (e.g. *Senior Distributed Systems*, *Storage & Consistency*, *Caching & Read Performance*) to number its concepts in prerequisite order on the canvas. **Start Track Session** builds a study queue from those concepts โ due cards first, foundations before capstones. + +### Adaptive Prerequisite Remediation + +When you rate an advanced card **Again**, the engine checks whether the failure stems from a broken foundation. It inspects the concept's direct prerequisites for *shaky* cards โ never studied, still learning, lapsed, overdue, or low ease โ and queues up to three of them into your next review session. They appear first, marked with a red **Foundation Checkup** banner explaining which concept they support. Rock-solid foundations produce no remediation: the system only intervenes when the foundation is actually the problem. + +--- + +## BotE Calculator + +The Calculator page (`/calculator`, `โ8`) is a back-of-the-envelope capacity estimation sandbox. Every result recalculates instantly as you drag sliders โ no mental math friction between an assumption and its consequence. + +It is also available everywhere as a quick modal: press `โE`, or use the calculator buttons in the Chat header, the Guide, and the Builder toolbar. The modal and the full page share the same state, which persists across navigation and restarts. + +### Inputs + +Daily Active Users, requests per user, read:write ratio, peak multiplier, payload size, media percentage and size, retention, and replication factor. **Advanced assumptions** adds index/metadata overhead, cache working-set percentage, target QPS per server, CPU utilization target, and storage per shard. Scenario presets (URL Shortener, Video Streaming, E-Commerce Flash Sale, Chat, Social Feed, Ride Sharing) load realistic numbers in one click. + +### Outputs + +- **Traffic** โ average read/write QPS and peak QPS +- **Storage** โ ingestion rate, per-day, per-year, and retained total with replication and overhead +- **Cache & Memory** โ the 80/20 working-set cache size and the Redis-class node count +- **Bandwidth** โ peak ingress and egress +- **Hardware** โ app server count from target CPU saturation, and database shard count + +Every stat shows the formula that produced it, with your actual numbers substituted in. + +### Numbers Every Engineer Should Know + +The reference section carries the canonical latency table (L1 cache 0.5 ns โ cross-continent RTT 150 ms) and a powers-of-two table. Click any latency row to add it to the **Latency Budget** composer โ stack up a request path (e.g. 1 cross-AZ round trip + 2 NVMe reads) and compare the total against a 200 ms API SLO. + +### Audit My Math + +One click sends your assumptions and results to the AI, which reviews them against the selected scenario and returns a structured critique: a verdict, severity-ranked findings with concrete fixes, and real-world factors the estimate omits (index overhead, compression, replication lag buffers, CDN offload, โฆ). + +### Export + +The Export menu copies the summary as **Markdown with LaTeX formulas**, sends it into the **AI Chat** as a prefilled message, or appends it to any **Guide section**. + +--- + ## Pomodoro Timer -The Pomodoro timer is accessible globally from the sidebar. Click **Pomodoro** (`โ7`) to open the timer modal. +The Pomodoro timer is accessible globally from the sidebar. Click **Pomodoro** (`โ9`) to open the timer modal. ### Timer Features @@ -359,8 +428,14 @@ View database statistics: | `โ2` | Navigate to Guide | | `โ3` | Navigate to Builder | | `โ4` | Navigate to Flashcards | +| `โ5` | Navigate to Feynman | +| `โ6` | Navigate to Interleaved | +| `โ7` | Navigate to Knowledge Graph | +| `โ8` | Navigate to BotE Calculator | +| `โ9` | Toggle Pomodoro timer | | `โ,` | Navigate to Settings | | `โK` | Toggle AI Chat panel (on Guide, Builder, Flashcards pages) | +| `โE` | Toggle the quick BotE Calculator modal | | `โB` | Toggle sidebar collapse | | `โD` | Toggle Dark/Light Mode | | `โS` | Save board (in Builder) | diff --git a/package-lock.json b/package-lock.json index b95a445..9a6ee74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "ai": "^7.0.66", "better-sqlite3": "^12.10.0", "cors": "^2.8.6", + "d3-force": "^3.0.0", "dotenv": "^17.4.2", "express": "^5.2.1", "idb": "^8.0.3", diff --git a/package.json b/package.json index 9b90fe5..9a1765a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "toolbox", "private": true, - "version": "1.3.2", + "version": "1.4.0", "type": "module", "scripts": { "dev": "concurrently \"npm run dev:client\" \"npm run dev:server\"", @@ -22,6 +22,7 @@ "ai": "^7.0.66", "better-sqlite3": "^12.10.0", "cors": "^2.8.6", + "d3-force": "^3.0.0", "dotenv": "^17.4.2", "express": "^5.2.1", "idb": "^8.0.3", diff --git a/server/__tests__/graph_health.test.js b/server/__tests__/graph_health.test.js new file mode 100644 index 0000000..7fba38a --- /dev/null +++ b/server/__tests__/graph_health.test.js @@ -0,0 +1,147 @@ +import { describe, it, expect } from 'vitest' +import { + cardStatus, + nodeHealth, + computeGraphHealth, + prereqSatisfied, +} from '../graph/health.js' + +const NOW = new Date('2026-08-16T12:00:00Z') +const daysFromNow = (d) => new Date(NOW.getTime() + d * 24 * 3600 * 1000).toISOString() + +const card = (overrides = {}) => ({ + id: overrides.id || 'card-1', + front: 'f', + back: 'b', + state: 2, + ease_factor: 2.5, + interval: 30, + next_review: daysFromNow(10), + ...overrides, +}) + +describe('cardStatus', () => { + it('classifies by SRS state', () => { + expect(cardStatus(card({ state: 0 }), NOW)).toBe('new') + expect(cardStatus(card({ state: 1 }), NOW)).toBe('learning') + expect(cardStatus(card({ state: 3 }), NOW)).toBe('lapsed') + }) + + it('review cards: mastered needs ease โฅ 2.5 AND interval > 21', () => { + expect(cardStatus(card({ ease_factor: 2.5, interval: 22 }), NOW)).toBe('mastered') + expect(cardStatus(card({ ease_factor: 2.5, interval: 21 }), NOW)).toBe('maturing') + expect(cardStatus(card({ ease_factor: 2.49, interval: 30 }), NOW)).toBe('maturing') + }) + + it('review cards past next_review are due', () => { + expect(cardStatus(card({ next_review: daysFromNow(-1) }), NOW)).toBe('due') + }) + + it('review cards with crushed ease are fragile (lapsed)', () => { + expect(cardStatus(card({ ease_factor: 1.9 }), NOW)).toBe('lapsed') + }) + + it('missing SRS columns behave as defaults (legacy rows)', () => { + expect(cardStatus({ id: 'x' }, NOW)).toBe('new') + expect(cardStatus({ id: 'x', state: 2, interval: 30 }, NOW)).toBe('mastered') + }) +}) + +describe('nodeHealth', () => { + it('no cards โ unseen with zero strength', () => { + const { health, strength } = nodeHealth([], { now: NOW }) + expect(health).toBe('unseen') + expect(strength).toBe(0) + }) + + it('any lapsed card โ decayed', () => { + const { health } = nodeHealth([card(), card({ id: 'c2', state: 3 })], { now: NOW }) + expect(health).toBe('decayed') + }) + + it('a card in the remediation queue โ decayed', () => { + const { health } = nodeHealth([card()], { + remediationCardIds: new Set(['card-1']), + now: NOW, + }) + expect(health).toBe('decayed') + }) + + it('all cards mastered and nothing due within 48h โ mastered', () => { + const cards = [ + card({ interval: 30, next_review: daysFromNow(10) }), + card({ id: 'c2', interval: 40, next_review: daysFromNow(20) }), + ] + const { health, strength } = nodeHealth(cards, { now: NOW }) + expect(health).toBe('mastered') + expect(strength).toBe(1) + }) + + it('mastered cards due within 48 hours drop to due (review soon)', () => { + const cards = [card({ interval: 30, next_review: daysFromNow(1) })] + const { health } = nodeHealth(cards, { now: NOW }) + expect(health).toBe('due') + }) + + it('a mix of new and mastered stays yellow (still learning)', () => { + const cards = [card(), card({ id: 'c2', state: 0 })] + const { health } = nodeHealth(cards, { now: NOW }) + expect(health).toBe('due') + }) +}) + +describe('computeGraphHealth โ readiness and locking', () => { + // A tiny injected graph: foundation โ advanced + const nodes = [ + { id: 'foundation', pillarId: 'compute', topicId: null, keywords: ['foundation phrase'], components: [], summary: 's' }, + { id: 'advanced', pillarId: 'compute', topicId: null, keywords: ['advanced phrase'], components: [], summary: 's' }, + ] + const edges = [{ from: 'foundation', to: 'advanced' }] + + it('an unseen node whose prereqs have no cards is ready (no signal โ locked)', () => { + const { byNode } = computeGraphHealth([], { now: NOW, nodes, edges }) + expect(byNode.get('advanced').ready).toBe(true) + expect(byNode.get('advanced').locked).toBe(false) + }) + + it('an unseen node is locked while its prerequisite cards are mostly ungraduated', () => { + const cards = [ + card({ id: 'f1', front: 'foundation phrase', state: 0 }), + card({ id: 'f2', front: 'foundation phrase', state: 0 }), + ] + const { byNode } = computeGraphHealth(cards, { now: NOW, nodes, edges }) + expect(byNode.get('advanced').locked).toBe(true) + expect(byNode.get('advanced').ready).toBe(false) + expect(byNode.get('advanced').unsatisfiedPrereqs).toEqual(['foundation']) + }) + + it('unlocks once enough prerequisite cards graduate to review', () => { + const cards = [ + card({ id: 'f1', front: 'foundation phrase' }), + card({ id: 'f2', front: 'foundation phrase' }), + ] + const { byNode } = computeGraphHealth(cards, { now: NOW, nodes, edges }) + expect(byNode.get('advanced').locked).toBe(false) + expect(byNode.get('advanced').ready).toBe(true) + }) + + it('indexes each card to its nodes', () => { + const cards = [card({ id: 'a1', front: 'advanced phrase' })] + const { cardNodeIndex, byNode } = computeGraphHealth(cards, { now: NOW, nodes, edges }) + expect(cardNodeIndex.get('a1')).toEqual(['advanced']) + expect(byNode.get('advanced').counts.total).toBe(1) + expect(byNode.get('foundation').counts.total).toBe(0) + }) +}) + +describe('prereqSatisfied', () => { + it('is satisfied with no entry or no cards', () => { + expect(prereqSatisfied(undefined)).toBe(true) + expect(prereqSatisfied({ counts: { total: 0, due: 0, maturing: 0, mastered: 0 } })).toBe(true) + }) + + it('requires 60% of cards graduated', () => { + expect(prereqSatisfied({ counts: { total: 10, due: 2, maturing: 2, mastered: 2 } })).toBe(true) + expect(prereqSatisfied({ counts: { total: 10, due: 1, maturing: 2, mastered: 2 } })).toBe(false) + }) +}) diff --git a/server/__tests__/remediation.test.js b/server/__tests__/remediation.test.js new file mode 100644 index 0000000..207dc83 --- /dev/null +++ b/server/__tests__/remediation.test.js @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest' +import { planRemediation, isShaky, MAX_REMEDIATION_CARDS } from '../graph/remediation.js' + +const NOW = new Date('2026-08-16T12:00:00Z') +const daysFromNow = (d) => new Date(NOW.getTime() + d * 24 * 3600 * 1000).toISOString() + +// Injected micro-graph: two foundations feed one advanced concept. +const nodes = [ + { id: 'hashing', pillarId: 'distributed-mechanics', topicId: null, keywords: ['hash function basics'], components: [], summary: 's' }, + { id: 'partitioning', pillarId: 'distributed-mechanics', topicId: null, keywords: ['partitioning basics'], components: [], summary: 's' }, + { id: 'consistent-hashing', pillarId: 'distributed-mechanics', topicId: null, keywords: ['consistent hashing'], components: [], summary: 's' }, +] +const edges = [ + { from: 'hashing', to: 'partitioning' }, + { from: 'partitioning', to: 'consistent-hashing' }, +] + +const solid = (overrides = {}) => ({ + state: 2, + ease_factor: 2.6, + interval: 30, + next_review: daysFromNow(10), + prerequisite_id: null, + ...overrides, +}) + +const failedCard = { + id: 'adv-1', + front: 'Explain consistent hashing', + back: 'Hash ring where nodes own arcs.', + ...solid(), +} + +describe('isShaky', () => { + it('flags never-studied, learning, and relearning cards', () => { + expect(isShaky({ state: 0 }, NOW)).toBe(true) + expect(isShaky({ state: 1 }, NOW)).toBe(true) + expect(isShaky({ state: 3 }, NOW)).toBe(true) + }) + + it('flags weak review cards: low ease, short interval, or overdue', () => { + expect(isShaky(solid({ ease_factor: 2.2 }), NOW)).toBe(true) + expect(isShaky(solid({ interval: 3 }), NOW)).toBe(true) + expect(isShaky(solid({ next_review: daysFromNow(-1) }), NOW)).toBe(true) + }) + + it('does not flag solid review cards', () => { + expect(isShaky(solid(), NOW)).toBe(false) + }) +}) + +describe('planRemediation', () => { + it('queues a shaky card from the direct prerequisite node', () => { + const allCards = [ + failedCard, + { id: 'part-1', front: 'partitioning basics question', back: 'b', ...solid({ state: 0 }) }, + ] + const plan = planRemediation(failedCard, allCards, { now: NOW, nodes, edges }) + expect(plan).toHaveLength(1) + expect(plan[0].cardId).toBe('part-1') + expect(plan[0].nodeId).toBe('partitioning') + expect(plan[0].nodeName).toBe('partitioning') + }) + + it('only reaches one level up โ not transitive grandparents', () => { + const allCards = [ + failedCard, + { id: 'hash-1', front: 'hash function basics question', back: 'b', ...solid({ state: 0 }) }, + ] + // 'hashing' is a grandparent of 'consistent-hashing' โ not direct. + const plan = planRemediation(failedCard, allCards, { now: NOW, nodes, edges }) + expect(plan).toHaveLength(0) + }) + + it('queues nothing when the foundations are rock solid', () => { + const allCards = [ + failedCard, + { id: 'part-1', front: 'partitioning basics question', back: 'b', ...solid() }, + ] + const plan = planRemediation(failedCard, allCards, { now: NOW, nodes, edges }) + expect(plan).toHaveLength(0) + }) + + it('caps the plan and picks the weakest cards first', () => { + const allCards = [ + failedCard, + { id: 'p1', front: 'partitioning basics 1', back: 'b', ...solid({ state: 0, ease_factor: 2.5 }) }, + { id: 'p2', front: 'partitioning basics 2', back: 'b', ...solid({ ease_factor: 1.5, next_review: daysFromNow(-3) }) }, + { id: 'p3', front: 'partitioning basics 3', back: 'b', ...solid({ ease_factor: 1.8, next_review: daysFromNow(-2) }) }, + { id: 'p4', front: 'partitioning basics 4', back: 'b', ...solid({ ease_factor: 2.0, next_review: daysFromNow(-1) }) }, + { id: 'p5', front: 'partitioning basics 5', back: 'b', ...solid({ state: 1 }) }, + ] + const plan = planRemediation(failedCard, allCards, { now: NOW, nodes, edges }) + expect(plan).toHaveLength(MAX_REMEDIATION_CARDS) + // Weakest ease first + expect(plan[0].cardId).toBe('p2') + expect(plan[1].cardId).toBe('p3') + }) + + it('never queues the failed card itself', () => { + // Failed card's text also matches the prerequisite node. + const weird = { + id: 'adv-2', + front: 'consistent hashing vs partitioning basics', + back: 'both phrases here', + ...solid({ state: 3 }), + } + const plan = planRemediation(weird, [weird], { now: NOW, nodes, edges }) + expect(plan.every((p) => p.cardId !== 'adv-2')).toBe(true) + }) + + it('honors an explicit card-level prerequisite link first', () => { + const prereqCard = { id: 'manual-1', front: 'unrelated text', back: 'b', ...solid({ state: 0 }) } + const failed = { ...failedCard, prerequisite_id: 'manual-1' } + const allCards = [ + failed, + prereqCard, + { id: 'part-1', front: 'partitioning basics question', back: 'b', ...solid({ state: 0 }) }, + ] + const plan = planRemediation(failed, allCards, { now: NOW, nodes, edges }) + expect(plan[0].cardId).toBe('manual-1') + expect(plan[0].reason).toMatch(/Linked prerequisite/) + expect(plan.map((p) => p.cardId)).toContain('part-1') + }) + + it('returns empty for cards that match no graph node', () => { + const orphan = { id: 'o1', front: 'nothing here', back: 'nope', ...solid({ state: 3 }) } + const plan = planRemediation(orphan, [orphan], { now: NOW, nodes, edges }) + expect(plan).toEqual([]) + }) +}) diff --git a/server/db.js b/server/db.js index 9aa94b2..515dd87 100644 --- a/server/db.js +++ b/server/db.js @@ -227,6 +227,25 @@ function migrate() { // Column already exists } } + + // Prerequisite remediation queue (Knowledge Graph adaptive engine). + // A row = "surface this card in the next review session because a + // dependent concept lapsed". Rows resolve when the card is reviewed. + db.exec(` + CREATE TABLE IF NOT EXISTS remediation_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + card_id TEXT NOT NULL, + source_card_id TEXT NOT NULL, + node_id TEXT DEFAULT NULL, + node_name TEXT DEFAULT NULL, + reason TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + resolved_at TEXT DEFAULT NULL, + FOREIGN KEY (card_id) REFERENCES flashcards(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_remediation_open + ON remediation_queue (card_id) WHERE resolved_at IS NULL; + `) } migrate() diff --git a/server/graph/health.js b/server/graph/health.js new file mode 100644 index 0000000..087ab44 --- /dev/null +++ b/server/graph/health.js @@ -0,0 +1,162 @@ +/** + * @fileoverview Knowledge-graph health engine. + * + * Pure functions that turn raw flashcard SRS rows into per-node memory + * health. No database access here โ the route layer feeds card rows in, + * which keeps every rule unit-testable. + * + * Health buckets (spec): + * mastered - ๐ข every graded card holds: state=Review, ease โฅ 2.5, + * interval > 21 days, and nothing is due within 48h + * due - ๐ก something is learning, new, or scheduled within 48h + * decayed - ๐ด a card lapsed (relearning), sits in the remediation + * queue, or carries a hypercorrection-crushed ease + * unseen - โช no cards linked to the node yet + */ + +import { + GRAPH_NODES, + GRAPH_EDGES, + prerequisitesOf, + nodesForCard, +} from '../../src/utils/knowledgeGraph.js' + +export const MASTERED_MIN_EASE = 2.5 +export const MASTERED_MIN_INTERVAL_DAYS = 21 +export const DUE_SOON_HOURS = 48 +/** Ease this low means the card keeps lapsing โ treat as fragile. */ +export const FRAGILE_EASE = 2.0 +/** A prereq node counts as "satisfied" when this share of its cards graduated. */ +export const PREREQ_SATISFIED_RATIO = 0.6 + +/** + * Classify one flashcard row. + * @param {Object} card - Row with state, ease_factor, interval, next_review. + * @param {Date} [now] + * @returns {'new'|'learning'|'lapsed'|'due'|'maturing'|'mastered'} + */ +export function cardStatus(card, now = new Date()) { + const state = card.state || 0 + if (state === 0) return 'new' + if (state === 1) return 'learning' + if (state === 3) return 'lapsed' + // state === 2 (Review) + const ease = card.ease_factor ?? 2.5 + if (ease < FRAGILE_EASE) return 'lapsed' + if (card.next_review && new Date(card.next_review) <= now) return 'due' + if (ease >= MASTERED_MIN_EASE && (card.interval || 0) > MASTERED_MIN_INTERVAL_DAYS) { + return 'mastered' + } + return 'maturing' +} + +/** + * Aggregate card statuses into one node health bucket plus a 0โ1 + * strength score (for the panel's progress bar). + * + * @param {Array} cards - Card rows linked to the node. + * @param {Object} [options] + * @param {Set
| Power | Approx | Exact | Unit |
|---|---|---|---|
| 2^{row.power} | +{row.approx} | +{row.exact} | +{row.unit} | +
+ Compose a request path: click latency rows to stack them up. + Example: 1 cross-AZ round trip + 2 NVMe reads + 1 RAM pass. +
+ ) : ( + <> + {latencyBudget.map((item) => { + const ref = LATENCY_NUMBERS.find((l) => l.id === item.id) + if (!ref) return null + return ( ++ Back-of-the-envelope capacity estimation โ traffic, storage, cache, bandwidth, and hardware, recalculated live. +
+