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 + + + + + + + + @@ -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} [options.remediationCardIds] - Cards queued for remediation. + * @param {Date} [options.now] + * @returns {{ health: string, strength: number, counts: Object }} + */ +export function nodeHealth(cards, { remediationCardIds = new Set(), now = new Date() } = {}) { + const counts = { total: cards.length, new: 0, learning: 0, lapsed: 0, due: 0, maturing: 0, mastered: 0 } + if (cards.length === 0) { + return { health: 'unseen', strength: 0, counts } + } + + const SCORE = { new: 0, learning: 0.25, lapsed: 0.1, due: 0.4, maturing: 0.7, mastered: 1 } + let scoreSum = 0 + let hasRemediation = false + let dueSoon = false + const soonCutoff = new Date(now.getTime() + DUE_SOON_HOURS * 3600 * 1000) + + for (const card of cards) { + const status = cardStatus(card, now) + counts[status] += 1 + scoreSum += SCORE[status] + if (remediationCardIds.has(card.id)) hasRemediation = true + if ( + (card.state === 2 || card.state === 1 || card.state === 3) && + card.next_review && new Date(card.next_review) <= soonCutoff + ) { + dueSoon = true + } + } + + const strength = scoreSum / cards.length + let health + if (counts.lapsed > 0 || hasRemediation) { + health = 'decayed' + } else if (counts.mastered === counts.total && !dueSoon) { + health = 'mastered' + } else { + health = 'due' + } + return { health, strength, counts } +} + +/** + * Link every card to its graph nodes and compute health for all nodes. + * + * @param {Array} cards - All flashcard rows. + * @param {Object} [options] + * @param {Set} [options.remediationCardIds] + * @param {Date} [options.now] + * @param {Array} [options.nodes] + * @param {Array} [options.edges] + * @returns {{ byNode: Map, cardNodeIndex: Map }} + * byNode: nodeId โ†’ { health, strength, counts, cardIds } + * cardNodeIndex: cardId โ†’ nodeIds + */ +export function computeGraphHealth(cards, { + remediationCardIds = new Set(), + now = new Date(), + nodes = GRAPH_NODES, + edges = GRAPH_EDGES, +} = {}) { + const cardsByNode = new Map(nodes.map((n) => [n.id, []])) + const cardNodeIndex = new Map() + + for (const card of cards) { + const nodeIds = nodesForCard(card, nodes) + cardNodeIndex.set(card.id, nodeIds) + for (const nodeId of nodeIds) { + cardsByNode.get(nodeId)?.push(card) + } + } + + const byNode = new Map() + for (const node of nodes) { + const linked = cardsByNode.get(node.id) || [] + const { health, strength, counts } = nodeHealth(linked, { remediationCardIds, now }) + byNode.set(node.id, { health, strength, counts, cardIds: linked.map((c) => c.id) }) + } + + // Readiness pass โ€” needs every node's health first. + const prereqs = prerequisitesOf(edges) + for (const node of nodes) { + const entry = byNode.get(node.id) + const above = prereqs.get(node.id) || [] + const unsatisfied = above.filter((p) => !prereqSatisfied(byNode.get(p))) + entry.locked = unsatisfied.length > 0 + entry.unsatisfiedPrereqs = unsatisfied + entry.ready = entry.health === 'unseen' && !entry.locked + } + + return { byNode, cardNodeIndex } +} + +/** + * A prerequisite node is satisfied when the learner has demonstrably + * worked through it: enough of its cards graduated to Review state. + * Nodes without any cards can't gate their dependents (no signal โ‰  locked). + */ +export function prereqSatisfied(entry) { + if (!entry) return true + const { counts } = entry + if (counts.total === 0) return true + const graduated = counts.due + counts.maturing + counts.mastered + return graduated / counts.total >= PREREQ_SATISFIED_RATIO +} diff --git a/server/graph/remediation.js b/server/graph/remediation.js new file mode 100644 index 0000000..444f3a5 --- /dev/null +++ b/server/graph/remediation.js @@ -0,0 +1,131 @@ +/** + * @fileoverview Adaptive prerequisite remediation engine. + * + * When a learner fails an advanced card ("Again"), the engine checks + * whether the failure likely comes from a broken foundation: + * + * 1. Find the graph nodes the failed card belongs to. + * 2. Look one level up โ€” the direct prerequisite nodes. + * 3. Among the cards of those prerequisite nodes, pick the SHAKY ones + * (never studied, still learning, lapsed, overdue, or low ease). + * Rock-solid foundations produce no remediation โ€” the failure is + * then intrinsic to the card itself. + * 4. Queue at most MAX_REMEDIATION_CARDS of them for the next session, + * weakest first. + * + * `planRemediation` is pure (rows in, plan out). The route layer owns + * all database writes. + */ + +import { + GRAPH_NODES, + GRAPH_EDGES, + nodeMap, + prerequisitesOf, + nodesForCard, +} from '../../src/utils/knowledgeGraph.js' + +/** Cap per failure so one bad session cannot flood the queue. */ +export const MAX_REMEDIATION_CARDS = 3 +/** Ease below this marks a foundation card as shaky even in Review state. */ +export const SHAKY_EASE = 2.3 +/** Review cards younger than this interval (days) are not yet stable. */ +export const SHAKY_INTERVAL_DAYS = 4 + +/** + * Is this prerequisite card a plausible broken foundation? + * @param {Object} card - Flashcard row. + * @param {Date} [now] + */ +export function isShaky(card, now = new Date()) { + const state = card.state || 0 + if (state === 0) return true // never studied + if (state === 1 || state === 3) return true // learning / relearning + const ease = card.ease_factor ?? 2.5 + if (ease < SHAKY_EASE) return true + if ((card.interval || 0) < SHAKY_INTERVAL_DAYS) return true + if (card.next_review && new Date(card.next_review) <= now) return true // overdue + return false +} + +/** Sort shaky cards weakest-first: lowest ease, then most overdue. */ +function byWeakness(a, b) { + const easeA = a.ease_factor ?? 2.5 + const easeB = b.ease_factor ?? 2.5 + if (easeA !== easeB) return easeA - easeB + const nextA = a.next_review ? Date.parse(a.next_review) : 0 + const nextB = b.next_review ? Date.parse(b.next_review) : 0 + return nextA - nextB +} + +/** + * Build the remediation plan for one failed card. + * + * @param {Object} failedCard - The card just rated "Again" (full row). + * @param {Array} allCards - Every flashcard row (for prerequisite lookup). + * @param {Object} [options] + * @param {Date} [options.now] + * @param {Array} [options.nodes] - Graph nodes (injectable for tests). + * @param {Array} [options.edges] - Graph edges (injectable for tests). + * @returns {Array<{cardId: string, nodeId: string|null, nodeName: string|null, reason: string}>} + */ +export function planRemediation(failedCard, allCards, { + now = new Date(), + nodes = GRAPH_NODES, + edges = GRAPH_EDGES, +} = {}) { + const plan = [] + const planned = new Set() + const byId = nodeMap(nodes) + + // Explicit card-level prerequisite link wins first. + if (failedCard.prerequisite_id) { + const prereqCard = allCards.find((c) => c.id === failedCard.prerequisite_id) + if (prereqCard && prereqCard.id !== failedCard.id && isShaky(prereqCard, now)) { + plan.push({ + cardId: prereqCard.id, + nodeId: null, + nodeName: null, + reason: 'Linked prerequisite card of the concept you missed', + }) + planned.add(prereqCard.id) + } + } + + // Graph-level prerequisites: one hop up from the failed card's nodes. + const failedNodes = nodesForCard(failedCard, nodes) + const prereqIndex = prerequisitesOf(edges) + const prereqNodeIds = new Set() + for (const nodeId of failedNodes) { + for (const p of prereqIndex.get(nodeId) || []) prereqNodeIds.add(p) + } + // A node is never its own foundation. + for (const nodeId of failedNodes) prereqNodeIds.delete(nodeId) + + if (prereqNodeIds.size > 0) { + const candidates = [] + for (const card of allCards) { + if (card.id === failedCard.id || planned.has(card.id)) continue + const cardNodes = nodesForCard(card, nodes) + const hitNode = cardNodes.find((n) => prereqNodeIds.has(n)) + if (!hitNode) continue + if (!isShaky(card, now)) continue + candidates.push({ card, nodeId: hitNode }) + } + candidates.sort((a, b) => byWeakness(a.card, b.card)) + + for (const { card, nodeId } of candidates) { + if (plan.length >= MAX_REMEDIATION_CARDS) break + if (planned.has(card.id)) continue + plan.push({ + cardId: card.id, + nodeId, + nodeName: byId.get(nodeId)?.name || nodeId, + reason: `Foundation for ${failedNodes.map((n) => byId.get(n)?.name || n).join(', ')}`, + }) + planned.add(card.id) + } + } + + return plan.slice(0, MAX_REMEDIATION_CARDS) +} diff --git a/server/index.js b/server/index.js index 4bcab3e..5649fcf 100644 --- a/server/index.js +++ b/server/index.js @@ -15,6 +15,8 @@ import guideContentRoutes from './routes/guide_content.js' import profileRoutes from './routes/profile.js' import systemRoutes from './routes/system.js' import searchRoutes from './routes/search.js' +import graphRoutes from './routes/graph.js' +import calculatorRoutes from './routes/calculator.js' import { seedApiKeysFromEnv } from './providers/index.js' // Seed API keys from environment variables for all registered providers @@ -42,6 +44,8 @@ app.use('/api/guide-content', guideContentRoutes) app.use('/api/profile', profileRoutes) app.use('/api/system', systemRoutes) app.use('/api/search', searchRoutes) +app.use('/api/graph', graphRoutes) +app.use('/api/calculator', calculatorRoutes) // Health check app.get('/api/health', (req, res) => { diff --git a/server/routes/calculator.js b/server/routes/calculator.js new file mode 100644 index 0000000..7bcb969 --- /dev/null +++ b/server/routes/calculator.js @@ -0,0 +1,85 @@ +/** + * @fileoverview BotE Calculator API โ€” the AI sanity checker. + * + * POST /api/calculator/audit - "Audit My Math": structured AI critique + * of the user's capacity estimates. + */ + +import { Router } from 'express' +import { z } from 'zod' +import { runStructured } from '../ai/engine.js' +import logger from '../utils/logger.js' + +const router = Router() + +const AUDIT_SCHEMA = z.object({ + verdict: z.enum(['sound', 'revisit']) + .describe('"sound" when the estimates hold up for the scenario; "revisit" when something material is off'), + summary: z.string() + .describe('Two or three sentences: the overall quality of this estimate and the single most important improvement'), + findings: z.array( + z.object({ + severity: z.enum(['critical', 'warning', 'info']) + .describe('critical = the estimate is materially wrong; warning = a real-world factor was omitted; info = a refinement'), + area: z.string() + .describe('Short label: Traffic, Storage, Cache, Bandwidth, Hardware, or Assumptions'), + finding: z.string() + .describe('What is off or missing, with the concrete numbers involved'), + suggestion: z.string() + .describe('The specific fix: which input to change or which factor to add, with a suggested value'), + }) + ).describe('Ordered most severe first. Empty when everything holds up.'), + omittedFactors: z.array(z.string()) + .describe('Real-world factors this estimate ignores (index overhead, compression, replication lag buffers, connection overhead, cold storage tiering, etc.). Only list factors that matter at THIS scale.'), +}) + +/** + * POST /api/calculator/audit + * Body: { scenario, inputs, results, model } + * - scenario: { id, name, description } of the selected problem + * - inputs: the sanitized calculator inputs + * - results: formatted key results (strings with units) + */ +router.post('/audit', async (req, res) => { + const { scenario, inputs, results, model } = req.body + + if (!inputs || !results) { + return res.status(400).json({ message: 'inputs and results are required' }) + } + + const scenarioText = scenario && scenario.id !== 'custom' + ? `The user is sizing this system: "${scenario.name}" โ€” ${scenario.description}` + : 'The user is sizing a custom system (no named scenario).' + + const prompt = `You are a principal engineer auditing a candidate's back-of-the-envelope capacity estimate during a system design interview. + +${scenarioText} + +Their input assumptions: +${JSON.stringify(inputs, null, 2)} + +The computed results (from these standard formulas: QPS = DAU ร— req/user รท 86,400; storage = writes ร— size ร— retention ร— replication ร— (1 + overhead); cache = working-set % ร— daily read volume; servers = peak QPS รท (QPS/node ร— utilization)): +${JSON.stringify(results, null, 2)} + +Audit the estimate. Ground rules: +- Judge whether the INPUT ASSUMPTIONS are realistic for this scenario (e.g. a URL shortener with 2 KB payloads, or a flash sale with a 2ร— peak multiplier, deserve a flag). +- Check for omitted real-world factors ONLY when they materially change the answer at this scale: index/metadata overhead, compression ratios, replication lag buffers, write amplification, connection/TLS overhead, CDN offload of media egress, hot-partition skew, cold-storage tiering. +- Use the numbers given. Never invent traffic figures that contradict the inputs, and never demand precision beyond back-of-the-envelope (ยฑ2ร— is fine). +- If the estimate is broadly sound, say so โ€” verdict "sound", few or no findings. Do not manufacture problems. +- Keep every finding concrete and tied to a number the user can change.` + + try { + const audit = await runStructured({ + model, + prompt, + schema: AUDIT_SCHEMA, + feature: 'calculator/audit', + }) + res.json(audit) + } catch (err) { + logger.error('[calculator/audit] Error:', err.message) + res.status(500).json({ message: err.message || 'Failed to audit the estimate.' }) + } +}) + +export default router diff --git a/server/routes/decks.js b/server/routes/decks.js index 6aff7ab..1871de4 100644 --- a/server/routes/decks.js +++ b/server/routes/decks.js @@ -1,263 +1,87 @@ import { Router } from 'express' import { v4 as uuid } from 'uuid' import db from '../db.js' +import { + calculateNextSrsState, + getCardPreviews, + formatRelativeTime, + parseDeckSettings, +} from '../srs/scheduler.js' +import { planRemediation } from '../graph/remediation.js' +import logger from '../utils/logger.js' const router = Router() -/** - * @function calculateNextSrsState - * @description Anki-style Spaced Repetition Scheduling Algorithm (SM-2 variant). - * Supports New, Learning, Review, and Relearning states with sub-day step intervals. - * Incorporates a "Hypercorrection Penalty" based on self-reported confidence. - * - * Quality maps: - * 0, 1 -> Again (Failure) - * 2, 3 -> Hard (Pass with difficulty) - * 4 -> Good (Pass) - * 5 -> Easy (Pass easily) - * - * @param {Object} card - The current card state (ease_factor, interval, state, learning_step). - * @param {number} quality - User rating from 0-5. - * @param {Object} settings - Deck SRS settings (steps, lapse_steps, easy_bonus). - * @param {string} confidence - User confidence rating ('low', 'medium', 'high'). - * @returns {Object} The calculated next state properties for the flashcard. +/* + * SM-2 scheduling lives in server/srs/scheduler.js (shared with the + * knowledge-graph routes). Prerequisite remediation planning lives in + * server/graph/remediation.js. */ -function calculateNextSrsState(card, quality, settings, confidence = 'medium') { - const ease_factor = card.ease_factor !== undefined && card.ease_factor !== null ? card.ease_factor : 2.5 - const interval = card.interval || 0 - const state = card.state || 0 - const learningStep = card.learning_step || 0 - - const steps = settings?.steps ? settings.steps.split(' ').map(s => { - const val = parseInt(s) || 1 - const unit = s.endsWith('h') ? 'h' : 'm' - return { val, unit } - }) : [{ val: 1, unit: 'm' }, { val: 10, unit: 'm' }] - - const lapseSteps = settings?.lapse_steps ? settings.lapse_steps.split(' ').map(s => { - const val = parseInt(s) || 10 - const unit = s.endsWith('h') ? 'h' : 'm' - return { val, unit } - }) : [{ val: 10, unit: 'm' }] - - const easyBonus = settings?.easy_bonus || 1.3 - - let nextState = state - let nextLearningStep = learningStep - let nextInterval = interval - let nextRepetitions = card.repetitions || 0 - let nextEase = ease_factor - - const now = new Date() - let nextReviewDate = new Date() - - const addTime = (date, val, unit) => { - if (unit === 'h') { - return new Date(date.getTime() + val * 60 * 60 * 1000) - } - return new Date(date.getTime() + val * 60 * 1000) - } - - if (state === 0) { // New - if (quality < 3) { // Again - nextState = 1 // Learning - nextLearningStep = 0 - nextReviewDate = addTime(now, lapseSteps[0].val, lapseSteps[0].unit) - nextInterval = 0 - - // Hypercorrection Penalty - if (confidence === 'high') { - nextEase = Math.max(1.3, nextEase - 0.4) - nextReviewDate = addTime(now, Math.max(1, lapseSteps[0].val / 2), lapseSteps[0].unit) - } else { - nextEase = Math.max(1.3, nextEase - 0.2) - } - nextRepetitions = 0 - } else if (quality === 3) { // Hard - nextState = 1 // Learning - nextLearningStep = 0 - const firstVal = steps[0].val * (steps[0].unit === 'h' ? 60 : 1) - const secondVal = (steps[1] || steps[0]).val * ((steps[1] || steps[0]).unit === 'h' ? 60 : 1) - const stepVal = Math.round((firstVal + secondVal) / 2) - nextReviewDate = addTime(now, stepVal, 'm') - nextInterval = 0 - nextRepetitions = 0 - } else if (quality === 4) { // Good - nextState = 1 // Learning - if (steps.length > 1) { - nextLearningStep = 1 - nextReviewDate = addTime(now, steps[1].val, steps[1].unit) - nextRepetitions = 0 - } else { - // Graduate immediately - nextState = 2 // Review - nextLearningStep = 0 - nextInterval = 1 - nextReviewDate = new Date(now.getTime() + 1 * 24 * 60 * 60 * 1000) - nextRepetitions = 1 - } - } else { // Easy - nextState = 2 // Review - nextLearningStep = 0 - nextInterval = 4 - nextReviewDate = new Date(now.getTime() + 4 * 24 * 60 * 60 * 1000) - nextRepetitions = 1 - } - } - else if (state === 1) { // Learning - if (quality < 3) { // Again - nextLearningStep = 0 - nextReviewDate = addTime(now, steps[0].val, steps[0].unit) - nextInterval = 0 - } else if (quality === 3) { // Hard - const currentVal = steps[nextLearningStep].val * (steps[nextLearningStep].unit === 'h' ? 60 : 1) - const stepVal = Math.round(currentVal * 1.5) - nextReviewDate = addTime(now, stepVal, 'm') - nextInterval = 0 - } else if (quality === 4) { // Good - if (nextLearningStep < steps.length - 1) { - nextLearningStep += 1 - nextReviewDate = addTime(now, steps[nextLearningStep].val, steps[nextLearningStep].unit) - nextInterval = 0 - } else { - // Graduate - nextState = 2 // Review - nextLearningStep = 0 - nextInterval = 1 - nextReviewDate = new Date(now.getTime() + 1 * 24 * 60 * 60 * 1000) - nextRepetitions = 1 - } - } else { // Easy - nextState = 2 // Review - nextLearningStep = 0 - nextInterval = 4 - nextReviewDate = new Date(now.getTime() + 4 * 24 * 60 * 60 * 1000) - nextRepetitions = 1 - } - } - else if (state === 2) { // Review - if (quality < 3) { // Again (Lapse) - nextState = 3 // Relearning - nextLearningStep = 0 - - // Hypercorrection Penalty - if (confidence === 'high') { - nextEase = Math.max(1.3, ease_factor - 0.40) - nextReviewDate = addTime(now, Math.max(1, lapseSteps[0].val / 2), lapseSteps[0].unit) - } else { - nextEase = Math.max(1.3, ease_factor - 0.20) - nextReviewDate = addTime(now, lapseSteps[0].val, lapseSteps[0].unit) - } - - nextInterval = 0 - nextRepetitions = 0 - } else if (quality === 3) { // Hard - nextEase = Math.max(1.3, ease_factor - 0.15) - nextInterval = Math.max(1, Math.round(interval * 1.2)) - nextReviewDate = new Date(now.getTime() + nextInterval * 24 * 60 * 60 * 1000) - nextRepetitions += 1 - } else if (quality === 4) { // Good - nextInterval = Math.max(1, Math.round(interval * ease_factor)) - nextReviewDate = new Date(now.getTime() + nextInterval * 24 * 60 * 60 * 1000) - nextRepetitions += 1 - } else { // Easy - nextEase = ease_factor + 0.15 - nextInterval = Math.max(1, Math.round(interval * ease_factor * easyBonus)) - nextReviewDate = new Date(now.getTime() + nextInterval * 24 * 60 * 60 * 1000) - nextRepetitions += 1 - } - } - else if (state === 3) { // Relearning - if (quality < 3) { // Again - nextLearningStep = 0 - nextReviewDate = addTime(now, lapseSteps[0].val, lapseSteps[0].unit) - nextInterval = 0 - } else if (quality === 3) { // Hard - const currentVal = lapseSteps[nextLearningStep].val * (lapseSteps[nextLearningStep].unit === 'h' ? 60 : 1) - const stepVal = Math.round(currentVal * 1.5) - nextReviewDate = addTime(now, stepVal, 'm') - nextInterval = 0 - } else if (quality === 4) { // Good - if (nextLearningStep < lapseSteps.length - 1) { - nextLearningStep += 1 - nextReviewDate = addTime(now, lapseSteps[nextLearningStep].val, lapseSteps[nextLearningStep].unit) - nextInterval = 0 - } else { - // Graduate - nextState = 2 // Review - nextLearningStep = 0 - nextInterval = 1 - nextReviewDate = new Date(now.getTime() + 1 * 24 * 60 * 60 * 1000) - nextRepetitions = 1 - } - } else { // Easy - nextState = 2 // Review - nextLearningStep = 0 - nextInterval = 4 - nextReviewDate = new Date(now.getTime() + 4 * 24 * 60 * 60 * 1000) - nextRepetitions = 1 - } - } - - return { - ease_factor: Math.round(nextEase * 100) / 100, - interval: nextInterval, - repetitions: nextRepetitions, - state: nextState, - learning_step: nextLearningStep, - next_review: nextReviewDate.toISOString(), - last_reviewed: now.toISOString(), - } -} /** - * Get visual interval preview string for all 4 ratings (Again, Hard, Good, Easy) + * Merge unresolved remediation-queue cards into a session queue. + * These are "foundational checkups" scheduled by the knowledge graph + * after a lapse on a dependent concept: + * - a queued card already in the session gets tagged and moved first + * - a queued card missing from the session is prepended + * + * @param {Array} sessionCards - Cards already selected for the session. + * @param {string|null} deckId - Limit checkups to one deck, or null for all. + * @returns {Array} The reordered session with remediation cards leading. */ -function getCardPreviews(card, settings) { - const now = new Date() - - const againState = calculateNextSrsState(card, 1, settings) - const hardState = calculateNextSrsState(card, 3, settings) - const goodState = calculateNextSrsState(card, 4, settings) - const easyState = calculateNextSrsState(card, 5, settings) - - const formatStr = (nextReviewStr, interval, state) => { - if (state === 1 || state === 3) { - const diffMs = new Date(nextReviewStr) - now - const diffMins = Math.max(1, Math.round(diffMs / (60 * 1000))) - if (diffMins < 60) return `${diffMins}m` - const diffHours = Math.round(diffMins / 60) - if (diffHours < 24) return `${diffHours}h` - return `${Math.round(diffHours / 24)}d` +function applyRemediation(sessionCards, deckId) { + const rows = deckId + ? db.prepare(` + SELECT f.*, r.reason AS remediation_reason, r.node_name AS remediation_node + FROM remediation_queue r + JOIN flashcards f ON f.id = r.card_id + WHERE r.resolved_at IS NULL AND f.deck_id = ? + ORDER BY r.created_at ASC + LIMIT 10 + `).all(deckId) + : db.prepare(` + SELECT f.*, r.reason AS remediation_reason, r.node_name AS remediation_node + FROM remediation_queue r + JOIN flashcards f ON f.id = r.card_id + WHERE r.resolved_at IS NULL + ORDER BY r.created_at ASC + LIMIT 10 + `).all() + + if (rows.length === 0) return sessionCards + + const meta = new Map() + for (const row of rows) { + if (!meta.has(row.id)) meta.set(row.id, row) + } + + const leading = [] + const rest = [] + const present = new Set() + for (const card of sessionCards) { + const tag = meta.get(card.id) + if (tag) { + present.add(card.id) + leading.push({ + ...card, + is_remediation: 1, + remediation_reason: tag.remediation_reason, + remediation_node: tag.remediation_node, + }) } else { - if (interval < 30) return `${interval}d` - if (interval < 365) return `${Math.round(interval / 30)}mo` - return `${Math.round(interval / 365)}y` + rest.push(card) } } - return { - again: formatStr(againState.next_review, againState.interval, againState.state), - hard: formatStr(hardState.next_review, hardState.interval, hardState.state), - good: formatStr(goodState.next_review, goodState.interval, goodState.state), - easy: formatStr(easyState.next_review, easyState.interval, easyState.state) - } -} + // Queued cards that were not otherwise due join the front of the session. + const missing = rows + .filter((row) => !present.has(row.id)) + .map((row) => ({ ...row, is_remediation: 1 })) -/** - * Format ISO timestamp to relative time string. - */ -function formatRelativeTime(dateStr) { - if (!dateStr) return 'Never studied' - const date = new Date(dateStr) - const now = new Date() - const diffMs = now - date - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) - if (diffDays <= 0) return 'Today' - if (diffDays === 1) return 'Yesterday' - return `${diffDays} days ago` + return [...missing, ...leading, ...rest] } + /** * GET /api/decks * List all decks with card counts and due counts. @@ -286,14 +110,7 @@ router.get('/', (req, res) => { delete deck.last_reviewed_raw delete deck.reviewed_count - let settings = { new_limit: 20, review_limit: 200, steps: '1m 10m', lapse_steps: '10m', easy_bonus: 1.3 } - if (row.settings) { - try { - settings = { ...settings, ...JSON.parse(row.settings) } - } catch { - // ignore - } - } + const settings = parseDeckSettings(row.settings) return { ...deck, @@ -317,14 +134,7 @@ router.get('/:id', (req, res) => { const deck = db.prepare('SELECT * FROM decks WHERE id = ?').get(req.params.id) if (!deck) return res.status(404).json({ message: 'Deck not found' }) - let settings = { new_limit: 20, review_limit: 200, steps: '1m 10m', lapse_steps: '10m', easy_bonus: 1.3 } - if (deck.settings) { - try { - settings = { ...settings, ...JSON.parse(deck.settings) } - } catch { - // ignore - } - } + const settings = parseDeckSettings(deck.settings) const cards = db.prepare( 'SELECT * FROM flashcards WHERE deck_id = ? ORDER BY position' @@ -505,16 +315,12 @@ router.get('/all/cards/due', (req, res) => { LIMIT ? `) + const deckNames = new Map(allDecks.map(d => [d.id, d.name])) + const deckSettings = new Map(allDecks.map(d => [d.id, parseDeckSettings(d.settings)])) + for (const deck of allDecks) { const deckId = deck.id - let settings = { new_limit: 20, review_limit: 200, steps: '1m 10m', lapse_steps: '10m', easy_bonus: 1.3 } - if (deck.settings) { - try { - settings = { ...settings, ...JSON.parse(deck.settings) } - } catch { - // ignore - } - } + const settings = deckSettings.get(deckId) const newReviewedToday = getNewReviewedToday.get(deckId).count const reviewsToday = getReviewsToday.get(deckId).count @@ -543,7 +349,14 @@ router.get('/all/cards/due', (req, res) => { [allDueCards[i], allDueCards[j]] = [allDueCards[j], allDueCards[i]] } - res.json(allDueCards) + // Foundational checkups lead the session, ahead of the shuffled queue. + const withRemediation = applyRemediation(allDueCards, null).map(c => ({ + ...c, + deckName: c.deckName ?? (deckNames.get(c.deck_id) || ''), + srs_previews: c.srs_previews ?? getCardPreviews(c, deckSettings.get(c.deck_id) || parseDeckSettings(null)), + })) + + res.json(withRemediation) }) /** @@ -565,14 +378,7 @@ router.get('/:deckId/cards/due', (req, res) => { const deck = db.prepare('SELECT name, settings FROM decks WHERE id = ?').get(deckId) if (!deck) return res.status(404).json({ message: 'Deck not found' }) - let settings = { new_limit: 20, review_limit: 200, steps: '1m 10m', lapse_steps: '10m', easy_bonus: 1.3 } - if (deck.settings) { - try { - settings = { ...settings, ...JSON.parse(deck.settings) } - } catch { - // ignore - } - } + const settings = parseDeckSettings(deck.settings) // Count new cards started today const newReviewedToday = db.prepare(` @@ -626,7 +432,11 @@ router.get('/:deckId/cards/due', (req, res) => { LIMIT ? `).all(deckId, remainingNew) : [] - const combined = [...learningCards, ...reviewCards, ...newCards] + // Foundational checkups (knowledge-graph remediation) lead the session. + const combined = applyRemediation( + [...learningCards, ...reviewCards, ...newCards], + deckId + ) // Attach previews and deck name const combinedWithPreviews = combined.map(c => ({ @@ -767,10 +577,17 @@ router.put('/:deckId/cards/:cardId', (req, res) => { }) /** - * PUT /api/decks/:deckId/cards/:cardId/review - * Record a review for a card + * PUT|POST /api/decks/:deckId/cards/:cardId/review + * Record a review for a card. Registered for both verbs โ€” older clients + * and queued offline mutations send POST. + * + * Side effects beyond SM-2 scheduling: + * - Resolves any open remediation-queue rows for this card. + * - On a failure (quality < 3), asks the knowledge-graph remediation + * engine for shaky prerequisite cards and queues them for the next + * session. The response carries the plan so the UI can explain it. */ -router.put('/:deckId/cards/:cardId/review', (req, res) => { +function handleReview(req, res) { const { quality, confidence } = req.body if (quality === undefined || quality < 0 || quality > 5) { return res.status(400).json({ message: 'Quality must be 0-5' }) @@ -783,14 +600,7 @@ router.put('/:deckId/cards/:cardId/review', (req, res) => { if (!card) return res.status(404).json({ message: 'Card not found' }) const deck = db.prepare('SELECT settings FROM decks WHERE id = ?').get(req.params.deckId) - let settings = { new_limit: 20, review_limit: 200, steps: '1m 10m', lapse_steps: '10m', easy_bonus: 1.3 } - if (deck?.settings) { - try { - settings = { ...settings, ...JSON.parse(deck.settings) } - } catch { - // ignore - } - } + const settings = parseDeckSettings(deck?.settings) const updated = calculateNextSrsState(card, quality, settings, confidence) @@ -823,12 +633,55 @@ router.put('/:deckId/cards/:cardId/review', (req, res) => { ON CONFLICT(date) DO UPDATE SET count = count + 1 `).run() + // Reviewing a card settles its pending foundational checkup, if any. + db.prepare(` + UPDATE remediation_queue SET resolved_at = datetime('now') + WHERE card_id = ? AND resolved_at IS NULL + `).run(req.params.cardId) + + // On failure, queue shaky prerequisite cards for the next session. + let remediation = [] + if (quality < 3) { + try { + const allCards = db.prepare( + 'SELECT id, front, back, state, ease_factor, interval, next_review, prerequisite_id, source_topic_id FROM flashcards' + ).all() + const plan = planRemediation(card, allCards) + + const hasOpenRow = db.prepare( + 'SELECT 1 FROM remediation_queue WHERE card_id = ? AND resolved_at IS NULL' + ) + const insert = db.prepare(` + INSERT INTO remediation_queue (card_id, source_card_id, node_id, node_name, reason) + VALUES (?, ?, ?, ?, ?) + `) + for (const item of plan) { + if (hasOpenRow.get(item.cardId)) continue + insert.run(item.cardId, card.id, item.nodeId, item.nodeName, item.reason) + const prereqCard = allCards.find((c) => c.id === item.cardId) + remediation.push({ + cardId: item.cardId, + front: prereqCard?.front || '', + nodeName: item.nodeName, + reason: item.reason, + }) + } + } catch (err) { + // Remediation must never break the review flow. + logger.error('[decks/review] Remediation planning failed:', err.message) + } + } + const result = db.prepare('SELECT * FROM flashcards WHERE id = ?').get(req.params.cardId) // Append previews to result result.srs_previews = getCardPreviews(result, settings) + result.remediation = remediation res.json(result) -}) +} + +router.put('/:deckId/cards/:cardId/review', handleReview) +router.post('/:deckId/cards/:cardId/review', handleReview) /** * DELETE /api/decks/:deckId/cards/:cardId diff --git a/server/routes/graph.js b/server/routes/graph.js new file mode 100644 index 0000000..93cbe79 --- /dev/null +++ b/server/routes/graph.js @@ -0,0 +1,308 @@ +/** + * @fileoverview Knowledge Graph API. + * + * GET /api/graph - full graph with live SRS health + * GET /api/graph/nodes/:id - node detail for the slide-over panel + * POST /api/graph/nodes/:id/session - study session for one node's cards + * POST /api/graph/tracks/:id/session - study session for a learning track + * + * All health math lives in server/graph/health.js (pure). This file only + * reads rows and shapes responses. + */ + +import { Router } from 'express' +import db from '../db.js' +import { + GRAPH_NODES, + GRAPH_EDGES, + LEARNING_TRACKS, + nodeMap, + prerequisitesOf, + dependentsOf, + expandTrack, + validateGraph, +} from '../../src/utils/knowledgeGraph.js' +import { computeGraphHealth } from '../graph/health.js' +import { getCardPreviews, parseDeckSettings } from '../srs/scheduler.js' +import { PILLARS, BUILDER_COMPONENTS } from '../../src/utils/constants.js' +import logger from '../utils/logger.js' + +// Fail fast at boot when the shipped graph data is broken. +validateGraph(GRAPH_NODES, GRAPH_EDGES) + +const router = Router() + +const CARD_COLUMNS = + 'id, deck_id, front, back, state, ease_factor, interval, repetitions, next_review, last_reviewed, prerequisite_id, source_pillar_id, source_topic_id, source_section_id' + +/** All flashcards with the columns health + matching need. */ +function loadCards() { + return db.prepare(`SELECT ${CARD_COLUMNS} FROM flashcards`).all() +} + +/** Card ids sitting in the open remediation queue. */ +function openRemediationIds() { + const rows = db.prepare( + 'SELECT card_id FROM remediation_queue WHERE resolved_at IS NULL' + ).all() + return new Set(rows.map((r) => r.card_id)) +} + +/** Map: deckId โ†’ { name, settings } for preview generation. */ +function loadDeckIndex() { + const decks = db.prepare('SELECT id, name, settings FROM decks').all() + return new Map(decks.map((d) => [d.id, { name: d.name, settings: parseDeckSettings(d.settings) }])) +} + +/** Is this card studyable right now (due, learning, or brand new)? */ +function isStudyableNow(card, now = new Date()) { + const state = card.state || 0 + if (state === 0) return true + return !card.next_review || new Date(card.next_review) <= now +} + +/** Shape a card for a review session response. */ +function toSessionCard(card, deckIndex, nodeName) { + const deck = deckIndex.get(card.deck_id) + return { + ...card, + deckName: deck?.name || '', + nodeName, + srs_previews: getCardPreviews(card, deck?.settings || parseDeckSettings(null)), + } +} + +/** + * GET /api/graph + * The whole graph: node definitions merged with live health, plus edges, + * tracks (with progress), and aggregate stats. + */ +router.get('/', (req, res) => { + try { + const cards = loadCards() + const remediationCardIds = openRemediationIds() + const { byNode } = computeGraphHealth(cards, { remediationCardIds }) + + const pillarNames = new Map(PILLARS.map((p) => [p.id, p.name])) + const pillarColors = new Map(PILLARS.map((p) => [p.id, p.color])) + + const nodes = GRAPH_NODES.map((node) => { + const entry = byNode.get(node.id) + return { + id: node.id, + name: node.name, + pillarId: node.pillarId, + pillarName: pillarNames.get(node.pillarId) || node.pillarId, + pillarColor: pillarColors.get(node.pillarId) || '#818cf8', + topicId: node.topicId, + summary: node.summary, + health: entry.health, + strength: Math.round(entry.strength * 100) / 100, + counts: entry.counts, + ready: entry.ready, + locked: entry.locked, + } + }) + + const stats = { mastered: 0, due: 0, decayed: 0, unseen: 0 } + for (const n of nodes) stats[n.health] += 1 + + const tracks = LEARNING_TRACKS.map((track) => { + const nodeIds = expandTrack(track) + const mastered = nodeIds.filter((id) => byNode.get(id).health === 'mastered').length + return { + id: track.id, + name: track.name, + emoji: track.emoji, + description: track.description, + nodeIds, + nodeCount: nodeIds.length, + masteredCount: mastered, + } + }) + + res.json({ + nodes, + edges: GRAPH_EDGES, + tracks, + stats, + remediationCount: remediationCardIds.size, + }) + } catch (err) { + logger.error('[graph] Error:', err.message) + res.status(500).json({ message: 'Failed to compute knowledge graph.' }) + } +}) + +/** + * GET /api/graph/nodes/:id + * Everything the slide-over panel shows for one node: its cards with SRS + * state, prerequisite/dependent nodes with health, the linked guide topic, + * and whiteboards containing related components. + */ +router.get('/nodes/:id', (req, res) => { + const node = nodeMap().get(req.params.id) + if (!node) return res.status(404).json({ message: 'Node not found' }) + + try { + const cards = loadCards() + const remediationCardIds = openRemediationIds() + const now = new Date() + const { byNode, cardNodeIndex } = computeGraphHealth(cards, { remediationCardIds, now }) + const deckIndex = loadDeckIndex() + + const linkedCards = cards + .filter((c) => (cardNodeIndex.get(c.id) || []).includes(node.id)) + .map((c) => ({ + id: c.id, + deck_id: c.deck_id, + deckName: deckIndex.get(c.deck_id)?.name || '', + front: c.front, + state: c.state || 0, + ease_factor: c.ease_factor ?? 2.5, + interval: c.interval || 0, + next_review: c.next_review, + due: isStudyableNow(c, now), + remediation: remediationCardIds.has(c.id), + })) + + const healthOf = (id) => { + const e = byNode.get(id) + return { id, name: nodeMap().get(id)?.name || id, health: e.health, strength: e.strength } + } + const prereqs = (prerequisitesOf().get(node.id) || []).map(healthOf) + const dependents = (dependentsOf().get(node.id) || []).map(healthOf) + + // Guide deep link + progress + let guide = null + if (node.topicId) { + const pillar = PILLARS.find((p) => p.id === node.pillarId) + const topic = pillar?.topics.find((t) => t.id === node.topicId) + if (topic) { + const filled = db.prepare( + "SELECT COUNT(*) AS count FROM guide_content WHERE pillar_id = ? AND topic_id = ? AND content != ''" + ).get(node.pillarId, node.topicId).count + guide = { pillarId: node.pillarId, topicId: node.topicId, topicName: topic.name, filledSections: filled } + } + } + + // Whiteboards containing any of this node's builder components + const componentNames = new Set() + for (const category of BUILDER_COMPONENTS) { + for (const item of category.items) { + if (node.components.includes(item.id)) componentNames.add(item.name) + } + } + const boards = [] + if (componentNames.size > 0) { + for (const board of db.prepare('SELECT id, name, data FROM boards').all()) { + try { + const data = JSON.parse(board.data || '{}') + if ((data.nodes || []).some((n) => componentNames.has(n.name))) { + boards.push({ id: board.id, name: board.name }) + } + } catch { + // Unreadable board data โ€” skip + } + } + } + + const entry = byNode.get(node.id) + res.json({ + node: { + ...node, + health: entry.health, + strength: entry.strength, + counts: entry.counts, + ready: entry.ready, + locked: entry.locked, + unsatisfiedPrereqs: entry.unsatisfiedPrereqs, + }, + cards: linkedCards, + prereqs, + dependents, + guide, + boards, + }) + } catch (err) { + logger.error('[graph/nodes] Error:', err.message) + res.status(500).json({ message: 'Failed to load node details.' }) + } +}) + +/** + * POST /api/graph/nodes/:id/session + * Build a study session from one node's studyable cards (due first). + */ +router.post('/nodes/:id/session', (req, res) => { + const node = nodeMap().get(req.params.id) + if (!node) return res.status(404).json({ message: 'Node not found' }) + + try { + const cards = loadCards() + const now = new Date() + const { cardNodeIndex } = computeGraphHealth(cards, { now }) + const deckIndex = loadDeckIndex() + + const linked = cards.filter((c) => (cardNodeIndex.get(c.id) || []).includes(node.id)) + const studyable = linked.filter((c) => isStudyableNow(c, now)) + const session = studyable + .slice(0, 20) + .map((c) => toSessionCard(c, deckIndex, node.name)) + + res.json({ cards: session, nodeName: node.name }) + } catch (err) { + logger.error('[graph/session] Error:', err.message) + res.status(500).json({ message: 'Failed to build node session.' }) + } +}) + +/** + * POST /api/graph/tracks/:id/session + * Build a study session for a curated track: cards ordered by the + * track's prerequisite (topological) node order, due cards first + * within each node, capped at 30. + */ +router.post('/tracks/:id/session', (req, res) => { + const track = LEARNING_TRACKS.find((t) => t.id === req.params.id) + if (!track) return res.status(404).json({ message: 'Track not found' }) + + try { + const cards = loadCards() + const now = new Date() + const { cardNodeIndex } = computeGraphHealth(cards, { now }) + const deckIndex = loadDeckIndex() + const byId = nodeMap() + + const orderedNodeIds = expandTrack(track) + const session = [] + const used = new Set() + + for (const nodeId of orderedNodeIds) { + if (session.length >= 30) break + const nodeName = byId.get(nodeId)?.name || nodeId + const linked = cards.filter( + (c) => !used.has(c.id) && (cardNodeIndex.get(c.id) || []).includes(nodeId) + ) + const studyable = linked.filter((c) => isStudyableNow(c, now)) + // Due/learning cards first, then new cards โ€” both in stable order. + studyable.sort((a, b) => (a.state === 0 ? 1 : 0) - (b.state === 0 ? 1 : 0)) + for (const card of studyable) { + if (session.length >= 30) break + used.add(card.id) + session.push(toSessionCard(card, deckIndex, nodeName)) + } + } + + res.json({ + cards: session, + trackName: track.name, + nodeOrder: orderedNodeIds, + }) + } catch (err) { + logger.error('[graph/tracks] Error:', err.message) + res.status(500).json({ message: 'Failed to build track session.' }) + } +}) + +export default router diff --git a/server/srs/scheduler.js b/server/srs/scheduler.js new file mode 100644 index 0000000..3906053 --- /dev/null +++ b/server/srs/scheduler.js @@ -0,0 +1,280 @@ +/** + * @fileoverview SM-2 (Anki-variant) scheduling โ€” extracted from the decks + * route so the knowledge-graph routes can reuse previews and settings + * parsing. Behavior is unchanged; see docs/srs-algorithm.md. + */ + +export const DEFAULT_DECK_SETTINGS = { + new_limit: 20, + review_limit: 200, + steps: '1m 10m', + lapse_steps: '10m', + easy_bonus: 1.3, +} + +/** Parse a deck row's settings JSON, merged over the defaults. */ +export function parseDeckSettings(settingsJson) { + let settings = { ...DEFAULT_DECK_SETTINGS } + if (settingsJson) { + try { + settings = { ...settings, ...JSON.parse(settingsJson) } + } catch { + // Malformed settings โ€” fall back to defaults + } + } + return settings +} + +/** + * @function calculateNextSrsState + * @description Anki-style Spaced Repetition Scheduling Algorithm (SM-2 variant). + * Supports New, Learning, Review, and Relearning states with sub-day step intervals. + * Incorporates a "Hypercorrection Penalty" based on self-reported confidence. + * + * Quality maps: + * 0, 1 -> Again (Failure) + * 2, 3 -> Hard (Pass with difficulty) + * 4 -> Good (Pass) + * 5 -> Easy (Pass easily) + * + * @param {Object} card - The current card state (ease_factor, interval, state, learning_step). + * @param {number} quality - User rating from 0-5. + * @param {Object} settings - Deck SRS settings (steps, lapse_steps, easy_bonus). + * @param {string} confidence - User confidence rating ('low', 'medium', 'high'). + * @returns {Object} The calculated next state properties for the flashcard. + */ +export function calculateNextSrsState(card, quality, settings, confidence = 'medium') { + const ease_factor = card.ease_factor !== undefined && card.ease_factor !== null ? card.ease_factor : 2.5 + const interval = card.interval || 0 + const state = card.state || 0 + const learningStep = card.learning_step || 0 + + const steps = settings?.steps ? settings.steps.split(' ').map(s => { + const val = parseInt(s) || 1 + const unit = s.endsWith('h') ? 'h' : 'm' + return { val, unit } + }) : [{ val: 1, unit: 'm' }, { val: 10, unit: 'm' }] + + const lapseSteps = settings?.lapse_steps ? settings.lapse_steps.split(' ').map(s => { + const val = parseInt(s) || 10 + const unit = s.endsWith('h') ? 'h' : 'm' + return { val, unit } + }) : [{ val: 10, unit: 'm' }] + + const easyBonus = settings?.easy_bonus || 1.3 + + let nextState = state + let nextLearningStep = learningStep + let nextInterval = interval + let nextRepetitions = card.repetitions || 0 + let nextEase = ease_factor + + const now = new Date() + let nextReviewDate = new Date() + + const addTime = (date, val, unit) => { + if (unit === 'h') { + return new Date(date.getTime() + val * 60 * 60 * 1000) + } + return new Date(date.getTime() + val * 60 * 1000) + } + + if (state === 0) { // New + if (quality < 3) { // Again + nextState = 1 // Learning + nextLearningStep = 0 + nextReviewDate = addTime(now, lapseSteps[0].val, lapseSteps[0].unit) + nextInterval = 0 + + // Hypercorrection Penalty + if (confidence === 'high') { + nextEase = Math.max(1.3, nextEase - 0.4) + nextReviewDate = addTime(now, Math.max(1, lapseSteps[0].val / 2), lapseSteps[0].unit) + } else { + nextEase = Math.max(1.3, nextEase - 0.2) + } + nextRepetitions = 0 + } else if (quality === 3) { // Hard + nextState = 1 // Learning + nextLearningStep = 0 + const firstVal = steps[0].val * (steps[0].unit === 'h' ? 60 : 1) + const secondVal = (steps[1] || steps[0]).val * ((steps[1] || steps[0]).unit === 'h' ? 60 : 1) + const stepVal = Math.round((firstVal + secondVal) / 2) + nextReviewDate = addTime(now, stepVal, 'm') + nextInterval = 0 + nextRepetitions = 0 + } else if (quality === 4) { // Good + nextState = 1 // Learning + if (steps.length > 1) { + nextLearningStep = 1 + nextReviewDate = addTime(now, steps[1].val, steps[1].unit) + nextRepetitions = 0 + } else { + // Graduate immediately + nextState = 2 // Review + nextLearningStep = 0 + nextInterval = 1 + nextReviewDate = new Date(now.getTime() + 1 * 24 * 60 * 60 * 1000) + nextRepetitions = 1 + } + } else { // Easy + nextState = 2 // Review + nextLearningStep = 0 + nextInterval = 4 + nextReviewDate = new Date(now.getTime() + 4 * 24 * 60 * 60 * 1000) + nextRepetitions = 1 + } + } + else if (state === 1) { // Learning + if (quality < 3) { // Again + nextLearningStep = 0 + nextReviewDate = addTime(now, steps[0].val, steps[0].unit) + nextInterval = 0 + } else if (quality === 3) { // Hard + const currentVal = steps[nextLearningStep].val * (steps[nextLearningStep].unit === 'h' ? 60 : 1) + const stepVal = Math.round(currentVal * 1.5) + nextReviewDate = addTime(now, stepVal, 'm') + nextInterval = 0 + } else if (quality === 4) { // Good + if (nextLearningStep < steps.length - 1) { + nextLearningStep += 1 + nextReviewDate = addTime(now, steps[nextLearningStep].val, steps[nextLearningStep].unit) + nextInterval = 0 + } else { + // Graduate + nextState = 2 // Review + nextLearningStep = 0 + nextInterval = 1 + nextReviewDate = new Date(now.getTime() + 1 * 24 * 60 * 60 * 1000) + nextRepetitions = 1 + } + } else { // Easy + nextState = 2 // Review + nextLearningStep = 0 + nextInterval = 4 + nextReviewDate = new Date(now.getTime() + 4 * 24 * 60 * 60 * 1000) + nextRepetitions = 1 + } + } + else if (state === 2) { // Review + if (quality < 3) { // Again (Lapse) + nextState = 3 // Relearning + nextLearningStep = 0 + + // Hypercorrection Penalty + if (confidence === 'high') { + nextEase = Math.max(1.3, ease_factor - 0.40) + nextReviewDate = addTime(now, Math.max(1, lapseSteps[0].val / 2), lapseSteps[0].unit) + } else { + nextEase = Math.max(1.3, ease_factor - 0.20) + nextReviewDate = addTime(now, lapseSteps[0].val, lapseSteps[0].unit) + } + + nextInterval = 0 + nextRepetitions = 0 + } else if (quality === 3) { // Hard + nextEase = Math.max(1.3, ease_factor - 0.15) + nextInterval = Math.max(1, Math.round(interval * 1.2)) + nextReviewDate = new Date(now.getTime() + nextInterval * 24 * 60 * 60 * 1000) + nextRepetitions += 1 + } else if (quality === 4) { // Good + nextInterval = Math.max(1, Math.round(interval * ease_factor)) + nextReviewDate = new Date(now.getTime() + nextInterval * 24 * 60 * 60 * 1000) + nextRepetitions += 1 + } else { // Easy + nextEase = ease_factor + 0.15 + nextInterval = Math.max(1, Math.round(interval * ease_factor * easyBonus)) + nextReviewDate = new Date(now.getTime() + nextInterval * 24 * 60 * 60 * 1000) + nextRepetitions += 1 + } + } + else if (state === 3) { // Relearning + if (quality < 3) { // Again + nextLearningStep = 0 + nextReviewDate = addTime(now, lapseSteps[0].val, lapseSteps[0].unit) + nextInterval = 0 + } else if (quality === 3) { // Hard + const currentVal = lapseSteps[nextLearningStep].val * (lapseSteps[nextLearningStep].unit === 'h' ? 60 : 1) + const stepVal = Math.round(currentVal * 1.5) + nextReviewDate = addTime(now, stepVal, 'm') + nextInterval = 0 + } else if (quality === 4) { // Good + if (nextLearningStep < lapseSteps.length - 1) { + nextLearningStep += 1 + nextReviewDate = addTime(now, lapseSteps[nextLearningStep].val, lapseSteps[nextLearningStep].unit) + nextInterval = 0 + } else { + // Graduate + nextState = 2 // Review + nextLearningStep = 0 + nextInterval = 1 + nextReviewDate = new Date(now.getTime() + 1 * 24 * 60 * 60 * 1000) + nextRepetitions = 1 + } + } else { // Easy + nextState = 2 // Review + nextLearningStep = 0 + nextInterval = 4 + nextReviewDate = new Date(now.getTime() + 4 * 24 * 60 * 60 * 1000) + nextRepetitions = 1 + } + } + + return { + ease_factor: Math.round(nextEase * 100) / 100, + interval: nextInterval, + repetitions: nextRepetitions, + state: nextState, + learning_step: nextLearningStep, + next_review: nextReviewDate.toISOString(), + last_reviewed: now.toISOString(), + } +} + +/** + * Get visual interval preview string for all 4 ratings (Again, Hard, Good, Easy) + */ +export function getCardPreviews(card, settings) { + const now = new Date() + + const againState = calculateNextSrsState(card, 1, settings) + const hardState = calculateNextSrsState(card, 3, settings) + const goodState = calculateNextSrsState(card, 4, settings) + const easyState = calculateNextSrsState(card, 5, settings) + + const formatStr = (nextReviewStr, interval, state) => { + if (state === 1 || state === 3) { + const diffMs = new Date(nextReviewStr) - now + const diffMins = Math.max(1, Math.round(diffMs / (60 * 1000))) + if (diffMins < 60) return `${diffMins}m` + const diffHours = Math.round(diffMins / 60) + if (diffHours < 24) return `${diffHours}h` + return `${Math.round(diffHours / 24)}d` + } else { + if (interval < 30) return `${interval}d` + if (interval < 365) return `${Math.round(interval / 30)}mo` + return `${Math.round(interval / 365)}y` + } + } + + return { + again: formatStr(againState.next_review, againState.interval, againState.state), + hard: formatStr(hardState.next_review, hardState.interval, hardState.state), + good: formatStr(goodState.next_review, goodState.interval, goodState.state), + easy: formatStr(easyState.next_review, easyState.interval, easyState.state) + } +} + +/** + * Format ISO timestamp to relative time string. + */ +export function formatRelativeTime(dateStr) { + if (!dateStr) return 'Never studied' + const date = new Date(dateStr) + const now = new Date() + const diffMs = now - date + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + if (diffDays <= 0) return 'Today' + if (diffDays === 1) return 'Yesterday' + return `${diffDays} days ago` +} diff --git a/src/App.jsx b/src/App.jsx index ddfac53..a25f7c8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,10 +8,13 @@ import StudyPage from './pages/StudyPage' import SettingsPage from './pages/SettingsPage' import FeynmanPage from './pages/FeynmanPage' import InterleavedPage from './pages/InterleavedPage' +import CalculatorPage from './pages/CalculatorPage' +import GraphPage from './pages/GraphPage' import { processSyncQueue } from './utils/db' import AhaMoment from './components/shared/AhaMoment' import CommitModal from './components/chat/CommitModal' import TaskWorkingBar from './components/shared/TaskWorkingBar' +import CalculatorModal from './components/calculator/CalculatorModal' export default function App() { useEffect(() => { @@ -43,12 +46,15 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> + ) } diff --git a/src/__tests__/bote.test.js b/src/__tests__/bote.test.js new file mode 100644 index 0000000..b0b3272 --- /dev/null +++ b/src/__tests__/bote.test.js @@ -0,0 +1,230 @@ +import { describe, it, expect } from 'vitest' +import { + computeEstimates, + defaultInputs, + sanitizeInput, + sanitizeInputs, + formatCount, + formatBytes, + formatBytesPerSec, + formatBitrate, + formatQps, + formatLatency, + sumLatencyBudget, + buildMarkdownSummary, + LATENCY_NUMBERS, + SCENARIOS, + BOTE_INPUT_DEFS, +} from '../utils/bote' + +describe('computeEstimates โ€” reference case (1M DAU / 10 req / 10:1)', () => { + // Default inputs: dau=1M, requests=10, ratio=10:1, peak=3, + // payload=2KB, media=10% @ 500KB, retention=5y, RF=3, cache=20% + const est = computeEstimates(defaultInputs()) + + it('splits requests into reads and writes by the R:W ratio', () => { + expect(est.requestsPerDay).toBe(10_000_000) + expect(est.writesPerDay).toBeCloseTo(10_000_000 / 11, 0) + expect(est.readsPerDay).toBeCloseTo((10_000_000 * 10) / 11, 0) + }) + + it('computes average and peak QPS', () => { + expect(est.avgWriteQps).toBeCloseTo(10.52, 1) + expect(est.avgReadQps).toBeCloseTo(105.2, 0) + expect(est.avgTotalQps).toBeCloseTo(115.7, 0) + expect(est.peakTotalQps).toBeCloseTo(347.2, 0) + }) + + it('blends media into the average transfer size', () => { + // 2 KB payload + 10% ร— 500 KB media = 52 KB + expect(est.avgTransferBytes).toBe(52_000) + }) + + it('computes storage: day, year, and retained with replication', () => { + expect(est.ingestPerDayBytes).toBeCloseTo(4.727e10, -8) + expect(est.storagePerYearBytes).toBeCloseTo(1.725e13, -11) + // 5 years ร— 3 replicas โ‰ˆ 259 TB + expect(est.replicatedRetainedBytes).toBeCloseTo(2.588e14, -12) + expect(est.fiveYearReplicatedBytes).toBeCloseTo(est.replicatedRetainedBytes, -12) + }) + + it('computes the 80/20 working-set cache and node count', () => { + // 20% of ~473 GB daily read volume โ‰ˆ 94.5 GB + expect(est.cacheBytes).toBeCloseTo(9.455e10, -9) + // 64 GB nodes at 75% usable = 48 GB โ†’ 2 nodes + expect(est.cacheNodes).toBe(2) + }) + + it('computes peak bandwidth in bits per second', () => { + expect(est.ingressBps).toBeCloseTo(1.313e7, -5) + expect(est.egressBps).toBeCloseTo(1.313e8, -6) + }) + + it('computes hardware from peak QPS and shard size', () => { + // 347 QPS รท (1000 ร— 70%) โ†’ 1 server + expect(est.appServers).toBe(1) + // raw retained 86.3 TB รท 2 TB โ†’ 44 shards + expect(est.dbShards).toBe(44) + }) +}) + +describe('computeEstimates โ€” edge cases', () => { + it('returns all zeros for zero DAU (no NaN, no Infinity)', () => { + const est = computeEstimates({ ...defaultInputs(), dau: 0 }) + for (const [key, value] of Object.entries(est)) { + if (key === 'inputs') continue + expect(Number.isFinite(value), key).toBe(true) + // avgTransferBytes is a per-request size โ€” traffic-independent. + if (key === 'avgTransferBytes') continue + expect(value, key).toBe(0) + } + }) + + it('handles a pure-write system (ratio 0:1)', () => { + const est = computeEstimates({ ...defaultInputs(), readRatio: 0 }) + expect(est.readsPerDay).toBe(0) + expect(est.writesPerDay).toBe(est.requestsPerDay) + expect(est.cacheBytes).toBe(0) + expect(est.egressBps).toBe(0) + }) + + it('stays finite at extreme scale (2B DAU ร— 10K requests)', () => { + const est = computeEstimates({ + ...defaultInputs(), + dau: 2_000_000_000, + requestsPerUser: 10_000, + payloadKB: 100_000, + mediaPercent: 100, + mediaSizeKB: 1_000_000, + }) + for (const [key, value] of Object.entries(est)) { + if (key === 'inputs') continue + expect(Number.isFinite(value), key).toBe(true) + expect(value, key).toBeGreaterThanOrEqual(0) + } + // 2e13 requests/day โ‰ˆ 231M average QPS + expect(est.avgTotalQps).toBeCloseTo(2.315e8, -6) + }) + + it('replication factor scales retained storage linearly', () => { + const base = computeEstimates({ ...defaultInputs(), replicationFactor: 1 }) + const tripled = computeEstimates({ ...defaultInputs(), replicationFactor: 3 }) + expect(tripled.replicatedRetainedBytes).toBeCloseTo(base.replicatedRetainedBytes * 3, -6) + }) + + it('overhead percent inflates retained storage', () => { + const base = computeEstimates(defaultInputs()) + const withOverhead = computeEstimates({ ...defaultInputs(), overheadPercent: 30 }) + expect(withOverhead.rawRetainedBytes).toBeCloseTo(base.rawRetainedBytes * 1.3, -6) + }) +}) + +describe('input sanitization', () => { + it('clamps to the definition range', () => { + expect(sanitizeInput('dau', -5)).toBe(0) + expect(sanitizeInput('dau', 1e15)).toBe(2_000_000_000) + expect(sanitizeInput('replicationFactor', 0)).toBe(1) + expect(sanitizeInput('utilizationPercent', 500)).toBe(100) + }) + + it('replaces NaN and garbage with the default', () => { + expect(sanitizeInput('dau', 'not-a-number')).toBe(1_000_000) + expect(sanitizeInput('dau', NaN)).toBe(1_000_000) + expect(sanitizeInput('dau', Infinity)).toBe(1_000_000) + }) + + it('fills missing keys with defaults', () => { + const inputs = sanitizeInputs({ dau: 500 }) + expect(inputs.dau).toBe(500) + for (const def of BOTE_INPUT_DEFS) { + expect(inputs[def.key]).toBeDefined() + } + }) + + it('returns 0 for unknown keys', () => { + expect(sanitizeInput('nonexistent', 42)).toBe(0) + }) +}) + +describe('formatters', () => { + it('formats counts across magnitudes', () => { + expect(formatCount(0)).toBe('0') + expect(formatCount(999)).toBe('999') + expect(formatCount(1234)).toBe('1.23K') + expect(formatCount(1_000_000)).toBe('1M') + expect(formatCount(2.5e9)).toBe('2.5B') + expect(formatCount(7.2e12)).toBe('7.2T') + }) + + it('formats bytes with SI units up to EB', () => { + expect(formatBytes(0)).toBe('0 B') + expect(formatBytes(999)).toBe('999 B') + expect(formatBytes(1e3)).toBe('1 KB') + expect(formatBytes(1.5e6)).toBe('1.5 MB') + expect(formatBytes(1e15)).toBe('1 PB') + expect(formatBytes(3.21e18)).toBe('3.21 EB') + }) + + it('formats rates and QPS', () => { + expect(formatBytesPerSec(2.5e6)).toBe('2.5 MB/s') + expect(formatBitrate(1.313e9)).toBe('1.31 Gbps') + expect(formatQps(1160)).toBe('1.16K QPS') + }) + + it('formats latency from ns to seconds', () => { + expect(formatLatency(0.5)).toBe('0.5 ns') + expect(formatLatency(10_000)).toBe('10 ยตs') + expect(formatLatency(150e6)).toBe('150 ms') + expect(formatLatency(2.5e9)).toBe('2.5 s') + }) + + it('handles non-finite input gracefully', () => { + expect(formatCount(NaN)).toBe('โ€”') + expect(formatBytes(Infinity)).toBe('โ€”') + expect(formatLatency(NaN)).toBe('โ€”') + }) +}) + +describe('latency budget', () => { + it('sums items by count', () => { + const azRtt = LATENCY_NUMBERS.find((l) => l.id === 'az-rtt') + const nvme = LATENCY_NUMBERS.find((l) => l.id === 'nvme-read-4k') + const total = sumLatencyBudget([ + { id: 'az-rtt', count: 2 }, + { id: 'nvme-read-4k', count: 3 }, + ]) + expect(total).toBe(azRtt.ns * 2 + nvme.ns * 3) + }) + + it('ignores unknown ids and negative counts', () => { + expect(sumLatencyBudget([{ id: 'ghost', count: 5 }])).toBe(0) + expect(sumLatencyBudget([{ id: 'az-rtt', count: -2 }])).toBe(0) + expect(sumLatencyBudget([])).toBe(0) + }) +}) + +describe('scenarios and export', () => { + it('every scenario preset only sets known input keys', () => { + const knownKeys = new Set(BOTE_INPUT_DEFS.map((d) => d.key)) + for (const scenario of SCENARIOS) { + for (const key of Object.keys(scenario.inputs || {})) { + expect(knownKeys.has(key), `${scenario.id}.${key}`).toBe(true) + } + } + }) + + it('builds a markdown summary with LaTeX formulas and key results', () => { + const est = computeEstimates(defaultInputs()) + const md = buildMarkdownSummary(est, { + scenarioName: 'Social Feed', + latencyBudget: [{ id: 'az-rtt', count: 1 }], + }) + expect(md).toContain('## Back-of-the-Envelope Estimate โ€” Social Feed') + expect(md).toContain('$$QPS_{write}') + expect(md).toContain('### Storage') + expect(md).toContain('### Latency Budget') + expect(md).toContain('1 ร— Cross-AZ round trip (same region) = 1 ms') + expect(md).not.toContain('NaN') + expect(md).not.toContain('undefined') + }) +}) diff --git a/src/__tests__/calculator_page.test.jsx b/src/__tests__/calculator_page.test.jsx new file mode 100644 index 0000000..23fa4ff --- /dev/null +++ b/src/__tests__/calculator_page.test.jsx @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import CalculatorPage from '../pages/CalculatorPage' +import useCalcStore from '../stores/useCalcStore' +import { defaultInputs } from '../utils/bote' + +function renderPage() { + return render( + + + + ) +} + +beforeEach(() => { + useCalcStore.setState({ inputs: defaultInputs(), scenarioId: 'custom', latencyBudget: [] }) +}) + +describe('CalculatorPage', () => { + it('renders all result groups with live values', () => { + renderPage() + expect(screen.getByText('BotE Calculator')).toBeInTheDocument() + expect(screen.getByText('Avg Write QPS')).toBeInTheDocument() + expect(screen.getByText('Ingestion Rate')).toBeInTheDocument() + expect(screen.getByText(/Working-Set Cache/)).toBeInTheDocument() + expect(screen.getByText('Ingress')).toBeInTheDocument() + expect(screen.getByText('App Servers')).toBeInTheDocument() + // Reference default: 1M DAU ร— 10 req รท 86,400 รท 11 โ‰ˆ 10.5 write QPS + expect(screen.getByText('10.5')).toBeInTheDocument() + }) + + it('recalculates instantly when an input changes', () => { + renderPage() + const dauField = document.getElementById('calc-dau') + fireEvent.change(dauField, { target: { value: '10000000' } }) + fireEvent.blur(dauField) + // 10ร— the DAU โ†’ 10ร— the write QPS + expect(screen.getByText('105')).toBeInTheDocument() + }) + + it('shows zeros (not NaN) when DAU is zero', () => { + renderPage() + const dauField = document.getElementById('calc-dau') + fireEvent.change(dauField, { target: { value: '0' } }) + fireEvent.blur(dauField) + expect(document.body.textContent).not.toContain('NaN') + expect(document.body.textContent).not.toContain('Infinity') + }) + + it('applies a scenario preset', () => { + renderPage() + fireEvent.click(document.getElementById('calc-scenario-url-shortener')) + expect(useCalcStore.getState().scenarioId).toBe('url-shortener') + expect(useCalcStore.getState().inputs.dau).toBe(10_000_000) + expect(useCalcStore.getState().inputs.readRatio).toBe(100) + }) + + it('builds a latency budget from cheat-sheet clicks', () => { + renderPage() + fireEvent.click(document.getElementById('latency-az-rtt')) + fireEvent.click(document.getElementById('latency-az-rtt')) + fireEvent.click(document.getElementById('latency-nvme-read-4k')) + const budget = useCalcStore.getState().latencyBudget + expect(budget).toEqual([ + { id: 'az-rtt', count: 2 }, + { id: 'nvme-read-4k', count: 1 }, + ]) + // 2 ร— 1 ms + 10 ยตs โ‰ˆ 2.01 ms + expect(screen.getByText(/โ‰ˆ 2.01 ms/)).toBeInTheDocument() + }) + + it('sanitizes garbage input instead of crashing', () => { + renderPage() + const dauField = document.getElementById('calc-dau') + fireEvent.change(dauField, { target: { value: 'garbage' } }) + fireEvent.blur(dauField) + expect(document.body.textContent).not.toContain('NaN') + }) +}) diff --git a/src/__tests__/graph_page.test.jsx b/src/__tests__/graph_page.test.jsx new file mode 100644 index 0000000..f9db29a --- /dev/null +++ b/src/__tests__/graph_page.test.jsx @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import GraphPage from '../pages/GraphPage' + +const graphFixture = { + nodes: [ + { + id: 'client-server', name: 'Client-Server Model', pillarId: 'network-protocols', + pillarName: 'Network & API Protocols', pillarColor: '#60a5fa', topicId: 'request-response', + summary: 'Base pattern.', health: 'mastered', strength: 1, + counts: { total: 2, new: 0, learning: 0, lapsed: 0, due: 0, maturing: 0, mastered: 2 }, + ready: false, locked: false, + }, + { + id: 'http-rest', name: 'HTTP & REST APIs', pillarId: 'network-protocols', + pillarName: 'Network & API Protocols', pillarColor: '#60a5fa', topicId: 'request-response', + summary: 'Verbs and status codes.', health: 'due', strength: 0.5, + counts: { total: 3, new: 1, learning: 1, lapsed: 0, due: 1, maturing: 0, mastered: 0 }, + ready: false, locked: false, + }, + { + id: 'load-balancing', name: 'Load Balancing', pillarId: 'compute', + pillarName: 'Compute & Infrastructure', pillarColor: '#818cf8', topicId: 'traffic-gateways', + summary: 'Spread traffic.', health: 'unseen', strength: 0, + counts: { total: 0, new: 0, learning: 0, lapsed: 0, due: 0, maturing: 0, mastered: 0 }, + ready: true, locked: false, + }, + ], + edges: [ + { from: 'client-server', to: 'http-rest' }, + { from: 'http-rest', to: 'load-balancing' }, + ], + tracks: [ + { + id: 'senior-distributed', name: 'Senior Distributed Systems', emoji: '๐Ÿง ', + description: 'Deep end.', nodeIds: ['client-server', 'http-rest'], nodeCount: 2, masteredCount: 1, + }, + ], + stats: { mastered: 1, due: 1, decayed: 0, unseen: 1 }, + remediationCount: 0, +} + +const nodeDetailFixture = { + node: { + id: 'http-rest', name: 'HTTP & REST APIs', pillarId: 'network-protocols', + pillarName: 'Network & API Protocols', topicId: 'request-response', + summary: 'Verbs and status codes.', health: 'due', strength: 0.5, + counts: { total: 3 }, ready: false, locked: false, unsatisfiedPrereqs: [], + keywords: [], components: [], + }, + cards: [ + { id: 'c1', deck_id: 'd1', deckName: 'Protocols', front: 'What is REST?', state: 2, ease_factor: 2.5, interval: 10, next_review: null, due: true, remediation: false }, + ], + prereqs: [{ id: 'client-server', name: 'Client-Server Model', health: 'mastered', strength: 1 }], + dependents: [{ id: 'load-balancing', name: 'Load Balancing', health: 'unseen', strength: 0 }], + guide: { pillarId: 'network-protocols', topicId: 'request-response', topicName: 'Request-Response Protocols', filledSections: 2 }, + boards: [], +} + +vi.mock('../utils/api', () => ({ + graphApi: { + get: vi.fn(() => Promise.resolve(graphFixture)), + getNode: vi.fn(() => Promise.resolve(nodeDetailFixture)), + nodeSession: vi.fn(() => Promise.resolve({ cards: [], nodeName: 'HTTP & REST APIs' })), + trackSession: vi.fn(() => Promise.resolve({ cards: [], trackName: 'Senior Distributed Systems' })), + }, + flashcardsApi: { review: vi.fn() }, + chatApi: { evaluateInterceptor: vi.fn() }, + configApi: { get: vi.fn(() => Promise.resolve({})), getAvailableModels: vi.fn(() => Promise.resolve({ groups: [] })) }, +})) + +function renderPage() { + return render( + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GraphPage', () => { + it('renders nodes with health colors and the legend counts', async () => { + renderPage() + await waitFor(() => { + expect(document.getElementById('graph-node-client-server')).toBeInTheDocument() + }) + expect(document.getElementById('graph-node-http-rest')).toBeInTheDocument() + expect(document.getElementById('graph-node-load-balancing')).toBeInTheDocument() + // Legend renders health buckets ("Mastered" also appears as a filter button) + expect(screen.getAllByText('Mastered').length).toBeGreaterThanOrEqual(1) + expect(screen.getByText('Decayed / Fragile')).toBeInTheDocument() + }) + + it('filters nodes by readiness', async () => { + renderPage() + await waitFor(() => { + expect(document.getElementById('graph-node-client-server')).toBeInTheDocument() + }) + fireEvent.click(document.getElementById('graph-filter-mastered')) + expect(document.getElementById('graph-node-client-server').classList.contains('filtered-out')).toBe(false) + expect(document.getElementById('graph-node-http-rest').classList.contains('filtered-out')).toBe(true) + + fireEvent.click(document.getElementById('graph-filter-ready')) + expect(document.getElementById('graph-node-load-balancing').classList.contains('filtered-out')).toBe(false) + expect(document.getElementById('graph-node-client-server').classList.contains('filtered-out')).toBe(true) + }) + + it('filters nodes by search term', async () => { + renderPage() + await waitFor(() => { + expect(document.getElementById('graph-node-client-server')).toBeInTheDocument() + }) + fireEvent.change(document.getElementById('graph-search'), { target: { value: 'load bal' } }) + expect(document.getElementById('graph-node-load-balancing').classList.contains('filtered-out')).toBe(false) + expect(document.getElementById('graph-node-http-rest').classList.contains('filtered-out')).toBe(true) + }) + + it('opens the slide-over panel with deep links on node click', async () => { + renderPage() + await waitFor(() => { + expect(document.getElementById('graph-node-http-rest')).toBeInTheDocument() + }) + fireEvent.click(document.getElementById('graph-node-http-rest')) + await waitFor(() => { + expect(document.getElementById('graph-node-panel')).toBeInTheDocument() + }) + // Guide deep link + expect(screen.getByText(/Guide: Request-Response Protocols/)).toBeInTheDocument() + // Prerequisite and dependent links + expect(screen.getByText('Learn first')).toBeInTheDocument() + expect(screen.getByText('Unlocks')).toBeInTheDocument() + // Linked card + expect(screen.getByText('What is REST?')).toBeInTheDocument() + }) + + it('activates a learning track with numbered study order', async () => { + renderPage() + await waitFor(() => { + expect(document.getElementById('graph-track-select')).toBeInTheDocument() + }) + fireEvent.change(document.getElementById('graph-track-select'), { + target: { value: 'senior-distributed' }, + }) + expect(document.getElementById('graph-track-ribbon')).toBeInTheDocument() + expect(screen.getByText('Start Track Session')).toBeInTheDocument() + // Nodes outside the track dim out + expect(document.getElementById('graph-node-load-balancing').classList.contains('filtered-out')).toBe(true) + }) +}) diff --git a/src/__tests__/knowledge_graph.test.js b/src/__tests__/knowledge_graph.test.js new file mode 100644 index 0000000..d1371ca --- /dev/null +++ b/src/__tests__/knowledge_graph.test.js @@ -0,0 +1,190 @@ +import { describe, it, expect } from 'vitest' +import { + GRAPH_NODES, + GRAPH_EDGES, + LEARNING_TRACKS, + validateGraph, + topologicalSort, + nodeDepths, + ancestorsOf, + descendantsOf, + expandTrack, + nodesForCard, + prerequisitesOf, +} from '../utils/knowledgeGraph' +import { PILLARS } from '../utils/constants' + +describe('graph data integrity', () => { + it('the shipped graph validates (unique ids, no dangling edges, acyclic)', () => { + expect(() => validateGraph()).not.toThrow() + }) + + it('every node maps to a real pillar', () => { + const pillarIds = new Set(PILLARS.map((p) => p.id)) + for (const node of GRAPH_NODES) { + expect(pillarIds.has(node.pillarId), `${node.id} โ†’ ${node.pillarId}`).toBe(true) + } + }) + + it('every non-null topicId exists inside its pillar', () => { + for (const node of GRAPH_NODES) { + if (!node.topicId) continue + const pillar = PILLARS.find((p) => p.id === node.pillarId) + const topic = pillar.topics.find((t) => t.id === node.topicId) + expect(topic, `${node.id} โ†’ ${node.pillarId}/${node.topicId}`).toBeDefined() + } + }) + + it('every node has keywords and a summary', () => { + for (const node of GRAPH_NODES) { + expect(node.keywords.length, node.id).toBeGreaterThan(0) + expect(node.summary.length, node.id).toBeGreaterThan(10) + // Keywords must be lowercase โ€” matching lowercases the card text. + for (const kw of node.keywords) { + expect(kw).toBe(kw.toLowerCase()) + } + } + }) + + it('every learning track targets existing nodes', () => { + const ids = new Set(GRAPH_NODES.map((n) => n.id)) + for (const track of LEARNING_TRACKS) { + for (const target of track.targets) { + expect(ids.has(target), `${track.id} โ†’ ${target}`).toBe(true) + } + } + }) + + it('matches the spec chain: consistent hashing โ†’ distributed KV โ†’ virtual nodes', () => { + const ancestorsOfKv = ancestorsOf('distributed-kv') + expect(ancestorsOfKv.has('consistent-hashing')).toBe(true) + const ancestorsOfVnodes = ancestorsOf('virtual-nodes') + expect(ancestorsOfVnodes.has('distributed-kv')).toBe(true) + expect(ancestorsOfVnodes.has('consistent-hashing')).toBe(true) + }) +}) + +describe('cycle prevention', () => { + const nodes = [ + { id: 'a', pillarId: 'compute', topicId: null, keywords: ['aaa'], components: [], summary: 'a' }, + { id: 'b', pillarId: 'compute', topicId: null, keywords: ['bbb'], components: [], summary: 'b' }, + { id: 'c', pillarId: 'compute', topicId: null, keywords: ['ccc'], components: [], summary: 'c' }, + ] + + it('rejects a direct cycle', () => { + const edges = [{ from: 'a', to: 'b' }, { from: 'b', to: 'a' }] + expect(() => validateGraph(nodes, edges)).toThrow(/Cycle detected/) + }) + + it('rejects a transitive cycle', () => { + const edges = [ + { from: 'a', to: 'b' }, + { from: 'b', to: 'c' }, + { from: 'c', to: 'a' }, + ] + expect(() => validateGraph(nodes, edges)).toThrow(/Cycle detected/) + }) + + it('rejects self-loops and dangling edges', () => { + expect(() => validateGraph(nodes, [{ from: 'a', to: 'a' }])).toThrow(/Self-loop/) + expect(() => validateGraph(nodes, [{ from: 'a', to: 'ghost' }])).toThrow(/missing node/) + }) + + it('rejects duplicate node ids', () => { + expect(() => validateGraph([...nodes, { ...nodes[0] }], [])).toThrow(/Duplicate/) + }) +}) + +describe('traversal', () => { + it('topological sort places every prerequisite before its dependent', () => { + const order = topologicalSort() + expect(order.length).toBe(GRAPH_NODES.length) + const position = new Map(order.map((id, i) => [id, i])) + for (const edge of GRAPH_EDGES) { + expect( + position.get(edge.from) < position.get(edge.to), + `${edge.from} must sort before ${edge.to}` + ).toBe(true) + } + }) + + it('depths grow along prerequisite chains', () => { + const depths = nodeDepths() + expect(depths.get('client-server')).toBe(0) + expect(depths.get('http-rest')).toBe(1) + for (const edge of GRAPH_EDGES) { + expect( + depths.get(edge.to) > depths.get(edge.from), + `${edge.to} deeper than ${edge.from}` + ).toBe(true) + } + }) + + it('ancestors and descendants are consistent inverses', () => { + for (const nodeId of ['consensus', 'heavy-read-fanout']) { + for (const anc of ancestorsOf(nodeId)) { + expect(descendantsOf(anc).has(nodeId)).toBe(true) + } + } + }) +}) + +describe('learning tracks', () => { + it('expands to targets plus all transitive prerequisites, in order', () => { + for (const track of LEARNING_TRACKS) { + const expanded = expandTrack(track) + const expandedSet = new Set(expanded) + // All targets present + for (const target of track.targets) { + expect(expandedSet.has(target), `${track.id} keeps ${target}`).toBe(true) + } + // Closed under prerequisites + const prereqs = prerequisitesOf() + for (const id of expanded) { + for (const p of prereqs.get(id) || []) { + expect(expandedSet.has(p), `${track.id}: ${id} needs ${p}`).toBe(true) + } + } + // Ordered: prerequisites come first + const position = new Map(expanded.map((id, i) => [id, i])) + for (const id of expanded) { + for (const p of prereqs.get(id) || []) { + expect(position.get(p) < position.get(id)).toBe(true) + } + } + } + }) +}) + +describe('card โ†’ node matching', () => { + it('matches by keyword phrase', () => { + const card = { front: 'Explain Consistent Hashing', back: 'Nodes on a hash ring own arcs.' } + expect(nodesForCard(card)).toContain('consistent-hashing') + }) + + it('keyword matches beat the topic fallback', () => { + const card = { + front: 'What is a B-Tree index?', + back: 'A balanced tree that keeps database index lookups logarithmic.', + source_topic_id: 'relational-oltp', + } + const matches = nodesForCard(card) + expect(matches).toContain('db-indexing') + // Not diluted to every relational-oltp node + expect(matches).not.toContain('transactions-acid') + }) + + it('falls back to the source topic when no keyword hits', () => { + const card = { + front: 'Question with no matching phrases', + back: 'Nothing recognizable here.', + source_topic_id: 'circuit-breakers', + } + expect(nodesForCard(card)).toContain('circuit-breakers') + }) + + it('returns empty for unlinkable cards', () => { + const card = { front: 'Totally unrelated', back: 'Nothing.' } + expect(nodesForCard(card)).toEqual([]) + }) +}) diff --git a/src/components/calculator/AuditPanel.jsx b/src/components/calculator/AuditPanel.jsx new file mode 100644 index 0000000..2138d1e --- /dev/null +++ b/src/components/calculator/AuditPanel.jsx @@ -0,0 +1,131 @@ +import { useState } from 'react' +import { ShieldCheck, ShieldAlert, Sparkles, Loader2, AlertTriangle, Info, AlertOctagon } from 'lucide-react' +import useAppStore from '../../stores/appStore' +import { calculatorApi } from '../../utils/api' +import { + formatCount, + formatBytes, + formatBytesPerSec, + formatBitrate, +} from '../../utils/bote' + +const SEVERITY_META = { + critical: { icon: AlertOctagon, color: 'var(--color-error)', bg: 'var(--color-error-subtle)' }, + warning: { icon: AlertTriangle, color: 'var(--color-warning)', bg: 'var(--color-warning-subtle)' }, + info: { icon: Info, color: 'var(--color-info)', bg: 'var(--color-info-subtle)' }, +} + +/** + * "Audit My Math" โ€” sends the current inputs and computed results to the + * AI and renders a structured critique: verdict, ranked findings, and + * real-world factors the estimate omits. + */ +export default function AuditPanel({ estimates, scenario }) { + const selectedModel = useAppStore((s) => s.model) + const apiKeyConfigured = useAppStore((s) => s.apiKeyConfigured) + const addToast = useAppStore((s) => s.addToast) + + const [isAuditing, setIsAuditing] = useState(false) + const [audit, setAudit] = useState(null) + + const runAudit = async () => { + if (isAuditing) return + setIsAuditing(true) + setAudit(null) + try { + const result = await calculatorApi.audit({ + scenario: { id: scenario.id, name: scenario.name, description: scenario.description }, + inputs: estimates.inputs, + results: { + avgWriteQps: formatCount(estimates.avgWriteQps), + avgReadQps: formatCount(estimates.avgReadQps), + peakTotalQps: formatCount(estimates.peakTotalQps), + ingestionRate: formatBytesPerSec(estimates.ingestRateBytesPerSec), + storagePerYear: formatBytes(estimates.storagePerYearBytes), + retainedWithReplication: formatBytes(estimates.replicatedRetainedBytes), + workingSetCache: formatBytes(estimates.cacheBytes), + cacheNodes: estimates.cacheNodes, + peakIngress: formatBitrate(estimates.ingressBps), + peakEgress: formatBitrate(estimates.egressBps), + appServers: estimates.appServers, + dbShards: estimates.dbShards, + }, + model: selectedModel, + }) + setAudit(result) + } catch (err) { + addToast({ type: 'error', message: err.message || 'Audit failed. Check your AI settings.' }) + } finally { + setIsAuditing(false) + } + } + + return ( +
+
+
AI Sanity Check
+ +
+ + {!audit && !isAuditing && ( +

+ One click sends your assumptions and results to the AI, which checks them against + the {scenario.name} scenario for omitted real-world factors โ€” + index overhead, compression, replication lag buffers, CDN offload, and more. +

+ )} + + {audit && ( +
+
+ {audit.verdict === 'sound' + ? + : } + {audit.verdict === 'sound' ? 'Estimate holds up' : 'Worth revisiting'} +
+

{audit.summary}

+ + {audit.findings?.length > 0 && ( +
+ {audit.findings.map((f, idx) => { + const meta = SEVERITY_META[f.severity] || SEVERITY_META.info + const Icon = meta.icon + return ( +
+
+ + {f.area} + {f.severity} +
+
{f.finding}
+
โ†’ {f.suggestion}
+
+ ) + })} +
+ )} + + {audit.omittedFactors?.length > 0 && ( +
+
Factors this estimate ignores
+
    + {audit.omittedFactors.map((factor, idx) => ( +
  • {factor}
  • + ))} +
+
+ )} +
+ )} +
+ ) +} diff --git a/src/components/calculator/CalculatorModal.jsx b/src/components/calculator/CalculatorModal.jsx new file mode 100644 index 0000000..67acd19 --- /dev/null +++ b/src/components/calculator/CalculatorModal.jsx @@ -0,0 +1,64 @@ +import { useEffect } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { X, Calculator, Maximize2 } from 'lucide-react' +import useAppStore from '../../stores/appStore' +import CalculatorSandbox from './CalculatorSandbox' + +/** + * Quick-access BotE calculator, rendered globally so it opens on any + * page (Chat, Guide, Builder, โ€ฆ). Shares state with /calculator through + * useCalcStore. Toggled with โŒ˜โ‡งE or the header buttons. + */ +export default function CalculatorModal() { + const open = useAppStore((s) => s.calcModalOpen) + const setOpen = useAppStore((s) => s.setCalcModalOpen) + const navigate = useNavigate() + const location = useLocation() + + // Close on Escape + useEffect(() => { + if (!open) return + const handler = (e) => { if (e.key === 'Escape') setOpen(false) } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [open, setOpen]) + + // The full page already hosts the sandbox โ€” no modal on top of it. + if (!open || location.pathname.startsWith('/calculator')) return null + + return ( +
{ if (e.target === e.currentTarget) setOpen(false) }} + > + +
+ ) +} diff --git a/src/components/calculator/CalculatorSandbox.jsx b/src/components/calculator/CalculatorSandbox.jsx new file mode 100644 index 0000000..6cbd3e8 --- /dev/null +++ b/src/components/calculator/CalculatorSandbox.jsx @@ -0,0 +1,312 @@ +import { useMemo, useState } from 'react' +import { RotateCcw, ChevronDown, Sigma } from 'lucide-react' +import useCalcStore from '../../stores/useCalcStore' +import { + BOTE_INPUT_DEFS, + SCENARIOS, + computeEstimates, + formatCount, + formatBytes, + formatBytesPerSec, + formatBitrate, + CACHE_NODE_RAM_GB, +} from '../../utils/bote' +import LatencyReference from './LatencyReference' +import AuditPanel from './AuditPanel' +import ExportMenu from './ExportMenu' + +const SLIDER_STEPS = 200 + +/** Map a raw value to a slider position (log-aware). */ +function valueToSlider(def, value) { + if (!def.log) { + return ((value - def.min) / (def.max - def.min)) * SLIDER_STEPS + } + const lo = def.logFloor ?? 1 + if (value < lo) return 0 + const p = Math.log(value / lo) / Math.log(def.max / lo) + return Math.max(0, Math.min(1, p)) * SLIDER_STEPS +} + +/** Map a slider position back to a value, rounded to friendly numbers. */ +function sliderToValue(def, pos) { + const p = pos / SLIDER_STEPS + if (!def.log) { + const raw = def.min + p * (def.max - def.min) + return roundNice(raw) + } + if (p <= 0) return def.min + const lo = def.logFloor ?? 1 + return roundNice(lo * Math.pow(def.max / lo, p)) +} + +function roundNice(v) { + if (v >= 100) { + const mag = Math.pow(10, Math.floor(Math.log10(v)) - 1) + return Math.round(v / mag) * mag + } + if (v >= 10) return Math.round(v) + return Math.round(v * 10) / 10 +} + +/** One labeled input with a numeric field plus a (log) slider. */ +function InputRow({ def, value, onChange }) { + const [draft, setDraft] = useState(null) + + const commitDraft = () => { + if (draft !== null) { + onChange(def.key, draft) + setDraft(null) + } + } + + return ( +
+
+ +
+ setDraft(e.target.value)} + onBlur={commitDraft} + onKeyDown={(e) => { if (e.key === 'Enter') commitDraft() }} + /> + {def.unit} +
+
+
+ onChange(def.key, sliderToValue(def, Number(e.target.value)))} + aria-label={def.label} + /> + {value >= 1000 && {formatCount(value)}} +
+
+ ) +} + +/** One result metric: value, label, and the formula that produced it. */ +function Stat({ label, value, formula, hint, accent }) { + return ( +
+
{label}
+
{value}
+ {formula &&
{formula}
} +
+ ) +} + +/** + * The Back-of-the-Envelope sandbox: inputs on the left, live results on + * the right, latency reference and AI audit below. Used by both the full + * /calculator page and the global quick-access modal (`compact`). + */ +export default function CalculatorSandbox({ compact = false }) { + const inputs = useCalcStore((s) => s.inputs) + const scenarioId = useCalcStore((s) => s.scenarioId) + const setInput = useCalcStore((s) => s.setInput) + const applyScenario = useCalcStore((s) => s.applyScenario) + const reset = useCalcStore((s) => s.reset) + const latencyBudget = useCalcStore((s) => s.latencyBudget) + + const [advancedOpen, setAdvancedOpen] = useState(false) + + const est = useMemo(() => computeEstimates(inputs), [inputs]) + const scenario = SCENARIOS.find((s) => s.id === scenarioId) || SCENARIOS[0] + + const grouped = useMemo(() => ({ + traffic: BOTE_INPUT_DEFS.filter((d) => d.group === 'traffic'), + data: BOTE_INPUT_DEFS.filter((d) => d.group === 'data'), + advanced: BOTE_INPUT_DEFS.filter((d) => d.group === 'advanced'), + }), []) + + const i = est.inputs + + return ( +
+ {/* Scenario presets + actions */} +
+
+ {SCENARIOS.map((sc) => ( + + ))} +
+
+ + +
+
+ +
+ {/* โ”€โ”€ Inputs โ”€โ”€ */} +
+
Traffic Shape
+ {grouped.traffic.map((def) => ( + + ))} + +
Data & Durability
+ {grouped.data.map((def) => ( + + ))} + + + {advancedOpen && grouped.advanced.map((def) => ( + + ))} +
+ + {/* โ”€โ”€ Results โ”€โ”€ */} +
+
+
Traffic
+
+ + + +
+
+ +
+
Storage
+
+ + + + 0 ? ` ร— ${(1 + i.overheadPercent / 100).toFixed(2)} overhead` : ''}`} + hint="The number to say out loud in the interview." + accent + /> +
+
+ +
+
Cache & Memory
+
+ + +
+
+ +
+
Bandwidth (peak)
+
+ + +
+
+ +
+
Hardware
+
+ + +
+
+ + +
+
+ + {/* โ”€โ”€ Reference: latency sheet, powers of two, budget composer โ”€โ”€ */} + {!compact && ( +
+
+ Numbers Every Engineer Should Know +
+ +
+ )} +
+ ) +} diff --git a/src/components/calculator/ExportMenu.jsx b/src/components/calculator/ExportMenu.jsx new file mode 100644 index 0000000..19b1925 --- /dev/null +++ b/src/components/calculator/ExportMenu.jsx @@ -0,0 +1,178 @@ +import { useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Share2, Copy, MessageSquare, BookOpen, Check, Loader2 } from 'lucide-react' +import useAppStore from '../../stores/appStore' +import { buildMarkdownSummary } from '../../utils/bote' +import { PILLARS, BLUEPRINT_SECTIONS } from '../../utils/constants' +import { guideContentApi } from '../../utils/api' + +/** sessionStorage key LearningChat polls for a prefilled draft. */ +export const CHAT_DRAFT_KEY = 'toolbox_chat_draft' + +/** + * 1-click export of the current estimate: + * - Copy Markdown (with LaTeX formulas) to the clipboard + * - Send to Chat (prefills the AI chat input) + * - Append to a Guide section (pillar โ†’ topic โ†’ section picker) + */ +export default function ExportMenu({ results, scenario, latencyBudget }) { + const [open, setOpen] = useState(false) + const [copied, setCopied] = useState(false) + const [guidePickerOpen, setGuidePickerOpen] = useState(false) + const menuRef = useRef(null) + const navigate = useNavigate() + const setCalcModalOpen = useAppStore((s) => s.setCalcModalOpen) + const addToast = useAppStore((s) => s.addToast) + + useEffect(() => { + if (!open) return + const close = (e) => { + if (menuRef.current && !menuRef.current.contains(e.target)) setOpen(false) + } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, [open]) + + const markdown = () => + buildMarkdownSummary(results, { scenarioName: scenario.name, latencyBudget }) + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(markdown()) + setCopied(true) + addToast({ type: 'success', message: 'Estimate copied as Markdown' }) + setTimeout(() => setCopied(false), 1500) + } catch { + addToast({ type: 'error', message: 'Clipboard unavailable in this browser' }) + } + setOpen(false) + } + + const sendToChat = () => { + try { + sessionStorage.setItem( + CHAT_DRAFT_KEY, + `Here is my back-of-the-envelope estimate. Challenge my assumptions:\n\n${markdown()}` + ) + } catch { + // Session storage full/blocked โ€” the chat page will just open empty. + } + window.dispatchEvent(new CustomEvent('toolbox-chat-draft')) + setOpen(false) + setCalcModalOpen(false) + navigate('/chat') + } + + return ( +
+ + + {open && ( +
+ + + +
+ )} + + {guidePickerOpen && ( + setGuidePickerOpen(false)} + /> + )} +
+ ) +} + +/** Small modal: choose pillar โ†’ topic โ†’ section, then append the summary. */ +function GuidePicker({ markdown, onClose }) { + const addToast = useAppStore((s) => s.addToast) + const [pillarId, setPillarId] = useState('distributed-mechanics') + const pillar = PILLARS.find((p) => p.id === pillarId) || PILLARS[0] + const [topicId, setTopicId] = useState(pillar.topics[0]?.id) + const sections = BLUEPRINT_SECTIONS[pillarId] || [] + const [sectionId, setSectionId] = useState(sections[0]?.id) + const [isSaving, setIsSaving] = useState(false) + + const selectPillar = (id) => { + const next = PILLARS.find((p) => p.id === id) + setPillarId(id) + setTopicId(next?.topics[0]?.id) + setSectionId((BLUEPRINT_SECTIONS[id] || [])[0]?.id) + } + + const save = async () => { + if (!topicId || !sectionId || isSaving) return + setIsSaving(true) + try { + // Append below any existing notes instead of replacing them. + let existing = '' + try { + const current = await guideContentApi.getSection(pillarId, topicId, sectionId) + existing = current?.content || '' + } catch { + // Section empty โ€” nothing to preserve + } + const combined = existing ? `${existing}\n\n---\n\n${markdown}` : markdown + await guideContentApi.upsert(pillarId, topicId, sectionId, combined) + addToast({ type: 'success', message: 'Estimate saved to Guide notes' }) + onClose() + } catch (err) { + addToast({ type: 'error', message: err.message || 'Failed to save to Guide' }) + } finally { + setIsSaving(false) + } + } + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
Save estimate to Guide notes
+ + + +
+ + +
+
+
+ ) +} diff --git a/src/components/calculator/LatencyReference.jsx b/src/components/calculator/LatencyReference.jsx new file mode 100644 index 0000000..5155c69 --- /dev/null +++ b/src/components/calculator/LatencyReference.jsx @@ -0,0 +1,196 @@ +import { useState } from 'react' +import { Plus, Minus, Trash2, Clock3, Binary, ListPlus } from 'lucide-react' +import useCalcStore from '../../stores/useCalcStore' +import { + LATENCY_NUMBERS, + POWERS_OF_TWO, + HANDY_CONSTANTS, + formatLatency, + sumLatencyBudget, +} from '../../utils/bote' + +const TIER_COLORS = { + cpu: 'var(--color-accent)', + memory: 'var(--color-teal)', + disk: 'var(--color-warning)', + network: 'var(--color-info)', +} + +/** Reference SLOs the budget total is compared against. */ +const BUDGET_MARKS = [ + { label: 'Instant (100 ms)', ns: 100e6 }, + { label: 'Typical API SLO (200 ms)', ns: 200e6 }, + { label: 'Patience limit (1 s)', ns: 1e9 }, +] + +/** + * The interactive latency cheat sheet. Clicking a row adds that constant + * to the Latency Budget composer โ€” the "click-to-apply" mechanic that + * lets users assemble an end-to-end latency estimate for a request path. + */ +export default function LatencyReference() { + const [tab, setTab] = useState('latency') + const latencyBudget = useCalcStore((s) => s.latencyBudget) + const addLatencyItem = useCalcStore((s) => s.addLatencyItem) + const setLatencyCount = useCalcStore((s) => s.setLatencyCount) + const clearLatencyBudget = useCalcStore((s) => s.clearLatencyBudget) + + const totalNs = sumLatencyBudget(latencyBudget) + const sloMark = BUDGET_MARKS[1] + const budgetShare = Math.min(1, totalNs / sloMark.ns) + + return ( +
+
+ + +
+ +
+ {tab === 'latency' && ( +
+
+ Click a row to add it to the latency budget โ†’ +
+ {LATENCY_NUMBERS.map((row) => ( + + ))} +
+ )} + + {tab === 'powers' && ( +
+
Knowledge Graph โ€” prerequisite map with live SRS heatmapBotE Calculator โ€” capacity estimation sandbox
Knowledge GraphBotE Calculator
Architecture Whiteboard Builder Structured Knowledge Guide
+ + + + + {POWERS_OF_TWO.map((row) => ( + + + + + + + ))} + +
PowerApproxExactUnit
2^{row.power}{row.approx}{row.exact}{row.unit}
+
+ {HANDY_CONSTANTS.map((c) => ( +
+ {c.label} + {c.value} +
+ ))} +
+ + )} + + {/* Budget composer */} +
+
+ Latency Budget + {latencyBudget.length > 0 && ( + + )} +
+ + {latencyBudget.length === 0 ? ( +

+ 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 ( +
+
+ + {item.count}ร— + +
+ {ref.label} + {formatLatency(ref.ns * item.count)} +
+ ) + })} +
+ Total + โ‰ˆ {formatLatency(totalNs)} +
+
+
+
sloMark.ns ? 'var(--color-error)' : 'var(--color-success)', + }} + /> +
+ + {totalNs > sloMark.ns + ? `Over the ${sloMark.label.toLowerCase()}` + : `${Math.round(budgetShare * 100)}% of the ${sloMark.label.toLowerCase()}`} + +
+ + )} +
+
+ + ) +} diff --git a/src/components/chat/LearningChat.jsx b/src/components/chat/LearningChat.jsx index 58d5e9a..9bb12b6 100644 --- a/src/components/chat/LearningChat.jsx +++ b/src/components/chat/LearningChat.jsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react' import { Send, Sparkles, Copy, Check, Trash2, GitCommit, - Plus, ChevronDown, Edit2, RotateCcw, Square, Map, Layers, MoreHorizontal + Plus, ChevronDown, Edit2, RotateCcw, Square, Map, Layers, MoreHorizontal, Calculator } from 'lucide-react' import MarkdownRenderer from '../shared/MarkdownRenderer' import FlashcardReviewModal from '../shared/FlashcardReviewModal' @@ -503,6 +503,26 @@ export default function LearningChat({ activeTopic, onCommitClick }) { // Focus input on mount useEffect(() => { inputRef.current?.focus() }, []) + // Pick up a draft handed over by the BotE Calculator export + // (sessionStorage 'toolbox_chat_draft' + 'toolbox-chat-draft' event). + useEffect(() => { + const importDraft = () => { + try { + const draft = sessionStorage.getItem('toolbox_chat_draft') + if (draft) { + sessionStorage.removeItem('toolbox_chat_draft') + setInput(draft) + inputRef.current?.focus() + } + } catch { + // Session storage unavailable โ€” nothing to import + } + } + importDraft() + window.addEventListener('toolbox-chat-draft', importDraft) + return () => window.removeEventListener('toolbox-chat-draft', importDraft) + }, []) + // Handle text selection for flashcard generation (works on both desktop mouseup and mobile touch selection) const checkSelection = useCallback((e) => { if (e?.target?.closest?.('#flashcard-popup') || e?.target?.closest?.('input') || e?.target?.closest?.('textarea') || e?.target?.closest?.('.flashcard-modal-ignore')) return @@ -893,6 +913,15 @@ export default function LearningChat({ activeTopic, onCommitClick }) {
+ {messages.length > 0 && (
+ + +
+
+ ) +} diff --git a/src/components/graph/NodePanel.jsx b/src/components/graph/NodePanel.jsx new file mode 100644 index 0000000..4c03c43 --- /dev/null +++ b/src/components/graph/NodePanel.jsx @@ -0,0 +1,202 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { + X, BookOpen, PenTool, GraduationCap, Loader2, Lock, + ArrowUpLeft, ArrowDownRight, Layers, Calculator, Wrench, +} from 'lucide-react' +import { graphApi } from '../../utils/api' +import { HEALTH_COLORS, HEALTH_LABELS } from './graphLayout' + +const STATE_LABELS = { 0: 'New', 1: 'Learning', 2: 'Review', 3: 'Relearning' } + +function HealthDot({ health }) { + return ( + + ) +} + +/** + * Slide-over panel for a selected graph node. Deep links into the Guide + * blueprint, related whiteboards, and the node's flashcards, and starts + * a focused study session for the node's due cards. + * + * @param {string} nodeId + * @param {Function} onClose + * @param {Function} onSelectNode - Jump to a prerequisite/dependent node. + * @param {Function} onStartSession - (cards, title) => void + * @param {number} refreshKey - Bumps to refetch after grading. + */ +export default function NodePanel({ nodeId, onClose, onSelectNode, onStartSession, refreshKey }) { + const [detail, setDetail] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isStarting, setIsStarting] = useState(false) + + useEffect(() => { + let cancelled = false + // eslint-disable-next-line react-hooks/set-state-in-effect + setIsLoading(true) + graphApi.getNode(nodeId) + .then((data) => { if (!cancelled) setDetail(data) }) + .catch(() => { if (!cancelled) setDetail(null) }) + .finally(() => { if (!cancelled) setIsLoading(false) }) + return () => { cancelled = true } + }, [nodeId, refreshKey]) + + const startSession = async () => { + if (isStarting) return + setIsStarting(true) + try { + const session = await graphApi.nodeSession(nodeId) + if (session?.cards?.length > 0) { + onStartSession(session.cards, session.nodeName) + } + } finally { + setIsStarting(false) + } + } + + const studyableCount = detail?.cards?.filter((c) => c.due).length || 0 + + return ( + + ) +} diff --git a/src/components/graph/graphLayout.js b/src/components/graph/graphLayout.js new file mode 100644 index 0000000..0fedab2 --- /dev/null +++ b/src/components/graph/graphLayout.js @@ -0,0 +1,103 @@ +/** + * @fileoverview Static force-directed layout for the knowledge graph. + * + * The layout runs d3-force synchronously (no animation loop): + * - a strong forceX pins each node's column to its prerequisite depth, + * so learning flows left โ†’ right + * - a weak forceY pulls nodes toward their pillar's band, keeping + * related concepts vertically clustered + * - collision + charge spread nodes apart + * + * Initial positions are deterministic, so the same graph always lays + * out the same way. + */ +import { + forceSimulation, + forceLink, + forceManyBody, + forceX, + forceY, + forceCollide, +} from 'd3-force' +import { nodeDepths } from '../../utils/knowledgeGraph' +import { PILLARS } from '../../utils/constants' + +const COLUMN_SPACING = 180 +const X_OFFSET = 110 +const BAND_HEIGHT = 130 +const PADDING = 70 + +/** + * @param {Array} nodes - Graph nodes (need id + pillarId). + * @param {Array} edges - [{ from, to }] + * @returns {{ positions: Map, width: number, height: number }} + */ +export function computeLayout(nodes, edges) { + if (nodes.length === 0) { + return { positions: new Map(), width: 800, height: 600 } + } + + const depths = nodeDepths(nodes, edges) + const pillarOrder = new Map(PILLARS.map((p, i) => [p.id, i])) + const bandCenter = (pillarId) => + PADDING + ((pillarOrder.get(pillarId) ?? 3) + 0.5) * BAND_HEIGHT + + const simNodes = nodes.map((n, idx) => ({ + id: n.id, + depth: depths.get(n.id) ?? 0, + band: bandCenter(n.pillarId), + // Deterministic starting positions โ€” no randomness in the layout. + x: X_OFFSET + (depths.get(n.id) ?? 0) * COLUMN_SPACING, + y: bandCenter(n.pillarId) + ((idx % 7) - 3) * 14, + })) + const simLinks = edges.map((e) => ({ source: e.from, target: e.to })) + + const simulation = forceSimulation(simNodes) + .force('link', forceLink(simLinks).id((d) => d.id).distance(90).strength(0.12)) + .force('charge', forceManyBody().strength(-260)) + .force('x', forceX((d) => X_OFFSET + d.depth * COLUMN_SPACING).strength(0.85)) + .force('y', forceY((d) => d.band).strength(0.08)) + .force('collide', forceCollide(40)) + .stop() + + for (let i = 0; i < 300; i++) simulation.tick() + + const positions = new Map() + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity + for (const n of simNodes) { + positions.set(n.id, { x: n.x, y: n.y }) + minX = Math.min(minX, n.x) + maxX = Math.max(maxX, n.x) + minY = Math.min(minY, n.y) + maxY = Math.max(maxY, n.y) + } + + // Normalize so everything sits in positive space with padding. + const dx = PADDING - minX + const dy = PADDING - minY + for (const pos of positions.values()) { + pos.x += dx + pos.y += dy + } + + return { + positions, + width: maxX - minX + PADDING * 2, + height: maxY - minY + PADDING * 2, + } +} + +/** Node fill/stroke colors per health bucket (matches the CSS legend). */ +export const HEALTH_COLORS = { + mastered: '#34d399', + due: '#fbbf24', + decayed: '#f87171', + unseen: '#71717a', +} + +export const HEALTH_LABELS = { + mastered: 'Mastered', + due: 'Learning / Due', + decayed: 'Decayed / Fragile', + unseen: 'Not started', +} diff --git a/src/components/layout/MobileDrawer.jsx b/src/components/layout/MobileDrawer.jsx index 26b9884..e3a039e 100644 --- a/src/components/layout/MobileDrawer.jsx +++ b/src/components/layout/MobileDrawer.jsx @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { X, Layers, BookOpen, PenTool, GraduationCap, Settings, BrainCircuit, Shuffle, MessageSquare } from 'lucide-react' +import { X, Layers, BookOpen, PenTool, GraduationCap, Settings, BrainCircuit, Shuffle, MessageSquare, Waypoints, Calculator } from 'lucide-react' import { NavLink } from 'react-router-dom' import PomodoroWidget from './PomodoroWidget' import useAppStore from '../../stores/appStore' @@ -11,6 +11,8 @@ const navItems = [ { to: '/study', icon: GraduationCap, label: 'Flashcards' }, { to: '/feynman', icon: BrainCircuit, label: 'Feynman' }, { to: '/interleaved', icon: Shuffle, label: 'Interleaved' }, + { to: '/graph', icon: Waypoints, label: 'Graph' }, + { to: '/calculator', icon: Calculator, label: 'Calculator' }, ] export default function MobileDrawer({ open, onClose }) { diff --git a/src/components/layout/PomodoroWidget.jsx b/src/components/layout/PomodoroWidget.jsx index c378bd5..173910d 100644 --- a/src/components/layout/PomodoroWidget.jsx +++ b/src/components/layout/PomodoroWidget.jsx @@ -89,7 +89,7 @@ export default function PomodoroWidget() { {/* Shortcut or Controls */} {!collapsed && !isActive && ( - โŒ˜7 + โŒ˜9 )} {!collapsed && isActive && ( diff --git a/src/components/layout/Sidebar.jsx b/src/components/layout/Sidebar.jsx index f8e2bff..a646307 100644 --- a/src/components/layout/Sidebar.jsx +++ b/src/components/layout/Sidebar.jsx @@ -9,7 +9,9 @@ import { PanelLeftClose, Layers, BrainCircuit, - Shuffle + Shuffle, + Waypoints, + Calculator } from 'lucide-react' import useAppStore from '../../stores/appStore' import PomodoroWidget from './PomodoroWidget' @@ -26,6 +28,8 @@ const navItems = [ { to: '/study', icon: GraduationCap, label: 'Flashcards', shortcut: 'โŒ˜4' }, { to: '/feynman', icon: BrainCircuit, label: 'Feynman', shortcut: 'โŒ˜5' }, { to: '/interleaved', icon: Shuffle, label: 'Interleaved', shortcut: 'โŒ˜6' }, + { to: '/graph', icon: Waypoints, label: 'Graph', shortcut: 'โŒ˜7' }, + { to: '/calculator', icon: Calculator, label: 'Calculator', shortcut: 'โŒ˜8' }, ], }, ] diff --git a/src/components/shared/KeyboardShortcutsModal.jsx b/src/components/shared/KeyboardShortcutsModal.jsx index 98e39c6..65cab32 100644 --- a/src/components/shared/KeyboardShortcutsModal.jsx +++ b/src/components/shared/KeyboardShortcutsModal.jsx @@ -13,6 +13,10 @@ export default function KeyboardShortcutsModal({ open, onClose }) { { label: 'Flashcards', keys: ['โŒ˜', '4'] }, { label: 'Feynman', keys: ['โŒ˜', '5'] }, { label: 'Interleaved', keys: ['โŒ˜', '6'] }, + { label: 'Knowledge Graph', keys: ['โŒ˜', '7'] }, + { label: 'BotE Calculator', keys: ['โŒ˜', '8'] }, + { label: 'Quick Calculator', keys: ['โŒ˜', 'E'] }, + { label: 'Toggle Pomodoro', keys: ['โŒ˜', '9'] }, { label: 'Settings', keys: ['โŒ˜', ','] }, { label: 'Global Search', keys: ['โŒ˜', '/'] }, { label: 'Toggle Sidebar', keys: ['โŒ˜', '\\'] }, diff --git a/src/components/study/FlashcardView.jsx b/src/components/study/FlashcardView.jsx index 763beb4..148d1c1 100644 --- a/src/components/study/FlashcardView.jsx +++ b/src/components/study/FlashcardView.jsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react' -import { ChevronLeft, ChevronRight, RotateCcw, Shuffle, ArrowLeft, Clock, Keyboard, BookOpen } from 'lucide-react' +import { ChevronLeft, ChevronRight, RotateCcw, Shuffle, ArrowLeft, Clock, Keyboard, BookOpen, Wrench } from 'lucide-react' import { Link } from 'react-router-dom' import { flashcardsApi, chatApi } from '../../utils/api' import { BLUEPRINT_SECTIONS } from '../../utils/constants' @@ -133,7 +133,21 @@ export default function FlashcardView({ cards = [], onBack, deckName, deckId, re try { // API call to update the card in database const updatedCardData = await flashcardsApi.review(targetDeckId, currentCard.id, quality, confidence) - + + // Let live views (Knowledge Graph heatmap) refresh without a reload + useAppStore.getState().bumpSrsVersion() + + // The adaptive engine queued prerequisite cards after this lapse + if (updatedCardData?.remediation?.length > 0) { + const names = updatedCardData.remediation + .map((r) => r.nodeName) + .filter(Boolean) + useAppStore.getState().addToast({ + type: 'info', + message: `Foundation checkup queued: ${updatedCardData.remediation.length} prerequisite card${updatedCardData.remediation.length === 1 ? '' : 's'}${names.length > 0 ? ` (${[...new Set(names)].join(', ')})` : ''} added to your next session`, + }) + } + if (quality === 5 && !skipInterceptor) { useAppStore.getState().triggerAhaMoment() } @@ -374,9 +388,23 @@ export default function FlashcardView({ cards = [], onBack, deckName, deckId, re + {/* Foundational checkup banner โ€” this card was queued by the + knowledge graph after a lapse on a dependent concept */} + {reviewMode && currentCard?.is_remediation === 1 && ( +
+ + + Foundation Checkup{currentCard.remediation_node ? ` ยท ${currentCard.remediation_node}` : ''} + + {currentCard.remediation_reason && ( + {currentCard.remediation_reason} + )} +
+ )} + {/* Card */} -
s.toggleSidebar) const toggleChat = useAppStore((s) => s.toggleChat) const toggleTheme = useAppStore((s) => s.toggleTheme) + const toggleCalcModal = useAppStore((s) => s.toggleCalcModal) useEffect(() => { const handler = (e) => { @@ -62,9 +69,24 @@ export default function useKeyboardShortcuts() { navigate('/interleaved') break case '7': + e.preventDefault() + navigate('/graph') + break + case '8': + e.preventDefault() + navigate('/calculator') + break + case '9': e.preventDefault() window.dispatchEvent(new CustomEvent('toggle-pomodoro')) break + case 'e': + case 'E': + if (!isEditable) { + e.preventDefault() + toggleCalcModal() + } + break case ',': e.preventDefault() navigate('/settings') @@ -106,5 +128,5 @@ export default function useKeyboardShortcuts() { window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) - }, [navigate, location, toggleSidebar, toggleChat, toggleTheme]) + }, [navigate, location, toggleSidebar, toggleChat, toggleTheme, toggleCalcModal]) } diff --git a/src/index.css b/src/index.css index c41f51c..1d0982d 100644 --- a/src/index.css +++ b/src/index.css @@ -4388,3 +4388,1412 @@ textarea.input { } } + +/* ============================================ + BotE CALCULATOR (/calculator + quick modal) + ============================================ */ + +.calc-page { + overflow-y: auto; + height: 100%; +} + +.calc-sandbox { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +/* Toolbar: scenario chips + actions */ +.calc-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; +} + +.calc-scenarios { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.calc-scenario-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: var(--radius-full); + border: 1px solid var(--color-border); + background: var(--color-bg-secondary); + color: var(--color-text-secondary); + font-size: var(--text-xs); + font-weight: 600; + cursor: pointer; + transition: all var(--duration-fast); +} + +.calc-scenario-chip:hover { + border-color: var(--color-border-strong); + color: var(--color-text-primary); +} + +.calc-scenario-chip.active { + background: var(--color-accent-subtle); + border-color: var(--color-border-accent); + color: var(--color-accent-text); +} + +.calc-actions { + display: flex; + gap: var(--space-2); + align-items: center; +} + +/* Two-column body: inputs | results */ +.calc-body { + display: grid; + grid-template-columns: 340px 1fr; + gap: var(--space-5); + align-items: start; +} + +.calc-sandbox.compact .calc-body { + grid-template-columns: 1fr; +} + +.calc-inputs { + display: flex; + flex-direction: column; + gap: var(--space-3); + background: var(--color-surface); + border: 1px solid var(--color-surface-border); + border-radius: var(--radius-lg); + padding: var(--space-4); +} + +.calc-section-title { + font-size: var(--text-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-tertiary); + margin-top: var(--space-2); +} + +.calc-section-title:first-child { + margin-top: 0; +} + +.calc-input-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.calc-input-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} + +.calc-input-label { + font-size: var(--text-sm); + color: var(--color-text-secondary); + cursor: help; +} + +.calc-input-value { + display: flex; + align-items: center; + gap: 4px; +} + +.calc-input-field { + width: 96px; + padding: 3px 6px; + text-align: right; + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-primary); + font-family: var(--font-mono); + font-size: var(--text-sm); +} + +.calc-input-field:focus { + outline: none; + border-color: var(--color-accent); +} + +/* Hide number spinners โ€” sliders cover coarse adjustment */ +.calc-input-field::-webkit-outer-spin-button, +.calc-input-field::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.calc-input-field[type='number'] { + -moz-appearance: textfield; + appearance: textfield; +} + +.calc-input-unit { + font-size: var(--text-xs); + color: var(--color-text-tertiary); + min-width: 30px; +} + +.calc-slider-row { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.calc-slider { + flex: 1; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--color-bg-active); + border-radius: var(--radius-full); + outline: none; + cursor: pointer; +} + +.calc-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--color-accent); + border: 2px solid var(--color-bg-primary); + cursor: grab; + transition: transform var(--duration-fast); +} + +.calc-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +.calc-slider::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--color-accent); + border: 2px solid var(--color-bg-primary); + cursor: grab; +} + +.calc-slider-echo { + font-size: var(--text-xs); + font-family: var(--font-mono); + color: var(--color-text-tertiary); + min-width: 44px; + text-align: right; +} + +.calc-advanced-toggle { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + color: var(--color-text-tertiary); + font-size: var(--text-xs); + font-weight: 600; + cursor: pointer; + padding: var(--space-2) 0 0; + border-top: 1px dashed var(--color-border); + margin-top: var(--space-2); +} + +.calc-advanced-toggle:hover { + color: var(--color-text-secondary); +} + +/* Results */ +.calc-results { + display: flex; + flex-direction: column; + gap: var(--space-4); + min-width: 0; +} + +.calc-group-title { + font-size: var(--text-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-tertiary); + margin-bottom: var(--space-2); +} + +.calc-group-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: var(--space-3); +} + +.calc-stat { + background: var(--color-surface); + border: 1px solid var(--color-surface-border); + border-radius: var(--radius-lg); + padding: var(--space-3) var(--space-4); + min-width: 0; + transition: border-color var(--duration-fast); +} + +.calc-stat:hover { + border-color: var(--color-surface-border-hover); +} + +.calc-stat.accent { + border-color: var(--color-border-accent); + background: linear-gradient(180deg, var(--color-accent-subtle), transparent 70%); +} + +.calc-stat-label { + font-size: var(--text-xs); + color: var(--color-text-secondary); + font-weight: 600; + margin-bottom: 2px; +} + +.calc-stat-value { + font-size: var(--text-xl); + font-weight: 700; + font-family: var(--font-mono); + color: var(--color-text-primary); + letter-spacing: var(--tracking-tight); + font-variant-numeric: tabular-nums; +} + +.calc-stat-formula { + margin-top: 4px; + font-size: 10px; + font-family: var(--font-mono); + color: var(--color-text-tertiary); + overflow-wrap: anywhere; +} + +/* AI Audit */ +.calc-audit { + background: var(--color-surface); + border: 1px solid var(--color-surface-border); + border-radius: var(--radius-lg); + padding: var(--space-4); +} + +.calc-audit-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.calc-audit-empty { + margin-top: var(--space-3); + font-size: var(--text-sm); + color: var(--color-text-tertiary); + line-height: var(--leading-relaxed); +} + +.calc-audit-result { + margin-top: var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.calc-audit-verdict { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 700; + font-size: var(--text-sm); +} + +.calc-audit-verdict.sound { color: var(--color-success); } +.calc-audit-verdict.revisit { color: var(--color-warning); } + +.calc-audit-summary { + font-size: var(--text-sm); + color: var(--color-text-secondary); + line-height: var(--leading-relaxed); +} + +.calc-audit-findings { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.calc-audit-finding { + border-radius: var(--radius-md); + padding: var(--space-3); +} + +.calc-audit-finding-head { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--text-xs); + font-weight: 700; + margin-bottom: 4px; +} + +.calc-audit-severity { + margin-left: auto; + text-transform: uppercase; + font-size: 9px; + letter-spacing: 0.06em; + opacity: 0.8; +} + +.calc-audit-finding-body { + font-size: var(--text-sm); + color: var(--color-text-primary); + line-height: var(--leading-relaxed); +} + +.calc-audit-finding-fix { + margin-top: 4px; + font-size: var(--text-xs); + color: var(--color-text-secondary); +} + +.calc-audit-omitted-title { + font-size: var(--text-xs); + font-weight: 700; + color: var(--color-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-2); +} + +.calc-audit-omitted ul { + margin: 0; + padding-left: var(--space-5); + display: flex; + flex-direction: column; + gap: 4px; + font-size: var(--text-sm); + color: var(--color-text-secondary); +} + +/* Export menu */ +.calc-export { + position: relative; +} + +.calc-export-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + min-width: 190px; + z-index: var(--z-popover); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.calc-export-item { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: none; + border: none; + color: var(--color-text-primary); + font-size: var(--text-sm); + cursor: pointer; + text-align: left; +} + +.calc-export-item:hover { + background: var(--color-bg-hover); +} + +/* Guide picker modal */ +.calc-guide-picker-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: var(--z-modal); + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-4); +} + +.calc-guide-picker { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + padding: var(--space-5); + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; + gap: var(--space-3); + box-shadow: var(--shadow-xl); +} + +.calc-guide-picker-title { + font-weight: 700; + font-size: var(--text-md); +} + +.calc-guide-picker-label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-text-secondary); +} + +.calc-guide-picker-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + margin-top: var(--space-2); +} + +/* Reference section */ +.calc-reference-wrap { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin-top: var(--space-2); +} + +.latency-reference { + background: var(--color-surface); + border: 1px solid var(--color-surface-border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.latency-tabs { + display: flex; + gap: var(--space-1); + padding: var(--space-2) var(--space-3) 0; + border-bottom: 1px solid var(--color-border); +} + +.latency-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: var(--space-2) var(--space-3); + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--color-text-tertiary); + font-size: var(--text-sm); + font-weight: 600; + cursor: pointer; +} + +.latency-tab.active { + color: var(--color-accent); + border-bottom-color: var(--color-accent); +} + +.latency-panels { + display: grid; + grid-template-columns: 1fr 320px; + gap: var(--space-4); + padding: var(--space-4); +} + +.latency-table { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.latency-table-hint { + font-size: var(--text-xs); + color: var(--color-text-tertiary); + margin-bottom: var(--space-2); +} + +.latency-row { + display: grid; + grid-template-columns: 10px minmax(150px, 1.2fr) 1fr 70px 16px; + align-items: center; + gap: var(--space-2); + padding: 4px var(--space-2); + background: none; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + text-align: left; + color: var(--color-text-secondary); +} + +.latency-row:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.latency-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.latency-label { + font-size: var(--text-sm); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.latency-bar-track { + height: 6px; + background: var(--color-bg-tertiary); + border-radius: var(--radius-full); + overflow: hidden; +} + +.latency-bar { + display: block; + height: 100%; + border-radius: var(--radius-full); + opacity: 0.75; +} + +.latency-value { + font-family: var(--font-mono); + font-size: var(--text-xs); + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--color-text-primary); +} + +.latency-add-icon { + opacity: 0; + color: var(--color-accent); + transition: opacity var(--duration-fast); +} + +.latency-row:hover .latency-add-icon { + opacity: 1; +} + +/* Powers of two */ +.powers-grid { + display: flex; + flex-direction: column; + gap: var(--space-4); + min-width: 0; +} + +.powers-table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-sm); +} + +.powers-table th { + text-align: left; + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-tertiary); + padding: var(--space-2); + border-bottom: 1px solid var(--color-border); +} + +.powers-table td { + padding: var(--space-2); + border-bottom: 1px solid var(--color-border); + color: var(--color-text-secondary); +} + +.powers-table .mono, +.handy-constant .mono { + font-family: var(--font-mono); + color: var(--color-text-primary); +} + +.handy-constants { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.handy-constant { + display: flex; + justify-content: space-between; + gap: var(--space-3); + font-size: var(--text-sm); + color: var(--color-text-secondary); + padding: var(--space-1) var(--space-2); +} + +/* Latency budget composer */ +.latency-budget { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3); + align-self: start; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.latency-budget-head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.latency-budget-empty { + font-size: var(--text-xs); + color: var(--color-text-tertiary); + line-height: var(--leading-relaxed); +} + +.latency-budget-item { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-xs); +} + +.latency-budget-stepper { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.latency-budget-stepper .btn { + width: 20px; + height: 20px; + min-width: 20px; + padding: 0; +} + +.latency-budget-count { + font-family: var(--font-mono); + min-width: 24px; + text-align: center; + color: var(--color-text-primary); +} + +.latency-budget-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text-secondary); +} + +.latency-budget-total { + display: flex; + justify-content: space-between; + border-top: 1px solid var(--color-border); + padding-top: var(--space-2); + font-size: var(--text-sm); + font-weight: 700; +} + +.latency-budget-total-value { + font-family: var(--font-mono); + color: var(--color-accent); +} + +.latency-budget-slo-track { + height: 6px; + background: var(--color-bg-tertiary); + border-radius: var(--radius-full); + overflow: hidden; +} + +.latency-budget-slo-fill { + height: 100%; + border-radius: var(--radius-full); + transition: width var(--duration-normal) var(--ease-default); +} + +.latency-budget-slo-label { + font-size: 10px; + color: var(--color-text-tertiary); +} + +/* Quick-access modal */ +.calc-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: var(--z-modal); + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-4); + animation: fadeIn var(--duration-fast) ease-out; +} + +.calc-modal { + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + width: 100%; + max-width: 860px; + max-height: 88vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: var(--shadow-xl); +} + +.calc-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.calc-modal-title { + display: flex; + align-items: center; + gap: var(--space-2); + font-weight: 700; + font-size: var(--text-md); +} + +.calc-modal-body { + overflow-y: auto; + padding: var(--space-4); +} + +/* Guide floating actions row */ +.guide-float-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-3) var(--space-4) 0; +} + +/* ============================================ + KNOWLEDGE GRAPH (/graph) + ============================================ */ + +.graph-page { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.graph-loading { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + height: 100%; + color: var(--color-text-tertiary); +} + +.graph-header { + padding: var(--space-4) var(--space-5) var(--space-3); + border-bottom: 1px solid var(--color-border); + display: flex; + flex-direction: column; + gap: var(--space-3); + flex-shrink: 0; +} + +.graph-header-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + flex-wrap: wrap; +} + +.graph-legend { + display: flex; + gap: var(--space-4); + flex-wrap: wrap; +} + +.graph-legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: var(--text-xs); + color: var(--color-text-secondary); +} + +.graph-health-dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; +} + +.graph-toolbar { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} + +.graph-search { + display: flex; + align-items: center; + gap: var(--space-2); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 0 var(--space-2) 0 var(--space-3); + color: var(--color-text-tertiary); + min-width: 220px; +} + +.graph-search:focus-within { + border-color: var(--color-accent); +} + +.graph-search-input { + background: none; + border: none; + outline: none; + color: var(--color-text-primary); + font-size: var(--text-sm); + padding: var(--space-2) 0; + flex: 1; + min-width: 0; +} + +.graph-readiness { + display: flex; + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 2px; + gap: 2px; +} + +.graph-readiness-btn { + padding: 4px 10px; + border: none; + background: none; + border-radius: var(--radius-sm); + color: var(--color-text-tertiary); + font-size: var(--text-xs); + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.graph-readiness-btn:hover { + color: var(--color-text-primary); +} + +.graph-readiness-btn.active { + background: var(--color-accent-subtle); + color: var(--color-accent-text); +} + +.graph-track-select { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-primary); + font-size: var(--text-sm); + padding: var(--space-2) var(--space-3); + cursor: pointer; + max-width: 320px; +} + +.graph-pillar-chips { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.graph-pillar-chip { + padding: 3px 10px; + border-radius: var(--radius-full); + border: 1px solid var(--color-border); + background: none; + color: var(--color-text-tertiary); + font-size: var(--text-xs); + font-weight: 600; + cursor: pointer; + transition: all var(--duration-fast); +} + +.graph-pillar-chip:hover { + border-color: var(--chip-color); + color: var(--color-text-primary); +} + +.graph-pillar-chip.active { + border-color: var(--chip-color); + color: var(--chip-color); + background: color-mix(in srgb, var(--chip-color) 12%, transparent); +} + +.graph-body { + flex: 1; + position: relative; + min-height: 0; + display: flex; +} + +.graph-canvas-wrap { + flex: 1; + position: relative; + overflow: hidden; + background: + radial-gradient(circle at 1px 1px, var(--color-border) 1px, transparent 0) 0 0 / 28px 28px, + var(--color-bg-primary); + cursor: grab; +} + +.graph-canvas-wrap:active { + cursor: grabbing; +} + +.graph-svg { + width: 100%; + height: 100%; + display: block; + touch-action: none; +} + +.graph-edge { + fill: none; + stroke: var(--color-border-strong); + stroke-width: 1.2; + opacity: 0.55; + transition: opacity var(--duration-fast); +} + +.graph-edge.faded { + opacity: 0.12; +} + +.graph-edge.up { + stroke: #818cf8; + stroke-width: 2; + opacity: 0.9; +} + +.graph-edge.down { + stroke: #2dd4bf; + stroke-width: 2; + opacity: 0.9; +} + +.graph-node { + cursor: pointer; +} + +.graph-node.filtered-out { + opacity: 0.12; + pointer-events: none; +} + +.graph-node-label { + font-size: 10px; + fill: var(--color-text-secondary); + text-anchor: middle; + pointer-events: none; + user-select: none; +} + +.graph-node.focus .graph-node-label, +.graph-node:hover .graph-node-label { + fill: var(--color-text-primary); + font-weight: 700; +} + +.graph-node-ready-ring { + fill: none; + stroke: var(--color-accent); + stroke-width: 1; + stroke-dasharray: 2 3; + opacity: 0.7; +} + +.graph-node-selected-ring { + fill: none; + stroke: var(--color-accent); + stroke-width: 1.5; + opacity: 0.9; +} + +.graph-node-track-badge circle { + fill: var(--color-accent); +} + +.graph-node-track-badge text { + font-size: 9px; + font-weight: 700; + fill: white; + text-anchor: middle; + pointer-events: none; +} + +.graph-zoom-controls { + position: absolute; + bottom: var(--space-4); + right: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-1); + z-index: 10; +} + +/* Node slide-over panel */ +.graph-node-panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 340px; + background: var(--color-surface); + border-left: 1px solid var(--color-border); + box-shadow: var(--shadow-xl); + overflow-y: auto; + padding: var(--space-5); + z-index: 20; + animation: slideInRight var(--duration-normal) var(--ease-default); +} + +@keyframes slideInRight { + from { transform: translateX(30px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +.graph-panel-close { + position: absolute; + top: var(--space-3); + right: var(--space-3); +} + +.graph-panel-loading { + display: flex; + justify-content: center; + padding: var(--space-8); + color: var(--color-text-tertiary); +} + +.graph-panel-header { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-bottom: var(--space-4); + padding-right: var(--space-6); +} + +.graph-panel-pillar { + font-size: var(--text-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.graph-panel-title { + font-size: var(--text-lg); + font-weight: 700; + line-height: var(--leading-tight); +} + +.graph-panel-health { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--text-xs); + color: var(--color-text-secondary); + flex-wrap: wrap; +} + +.graph-panel-locked { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--color-warning); + font-weight: 600; +} + +.graph-strength-track { + height: 5px; + background: var(--color-bg-tertiary); + border-radius: var(--radius-full); + overflow: hidden; +} + +.graph-strength-fill { + height: 100%; + border-radius: var(--radius-full); + transition: width var(--duration-slow) var(--ease-default); +} + +.graph-panel-summary { + font-size: var(--text-sm); + color: var(--color-text-secondary); + line-height: var(--leading-relaxed); +} + +.graph-panel-actions { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--space-2); + margin-bottom: var(--space-4); +} + +.graph-panel-actions .btn { + justify-content: flex-start; +} + +.graph-panel-badge { + margin-left: auto; + font-size: 10px; + background: var(--color-accent-subtle); + color: var(--color-accent-text); + padding: 1px 6px; + border-radius: var(--radius-full); +} + +.graph-panel-section { + margin-bottom: var(--space-4); +} + +.graph-panel-section-title { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--text-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-tertiary); + margin-bottom: var(--space-2); +} + +.graph-panel-node-link { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2); + background: none; + border: none; + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + font-size: var(--text-sm); + cursor: pointer; + text-align: left; +} + +.graph-panel-node-link:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.graph-panel-empty { + font-size: var(--text-xs); + color: var(--color-text-tertiary); + line-height: var(--leading-relaxed); +} + +.graph-panel-card { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + border-bottom: 1px solid var(--color-border); + font-size: var(--text-xs); +} + +.graph-panel-card-front { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text-secondary); +} + +.graph-panel-card-state { + flex-shrink: 0; + padding: 1px 7px; + border-radius: var(--radius-full); + font-weight: 700; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.graph-panel-card-state.s0 { background: var(--color-info-subtle); color: var(--color-info); } +.graph-panel-card-state.s1 { background: var(--color-warning-subtle); color: var(--color-warning); } +.graph-panel-card-state.s2 { background: var(--color-success-subtle); color: var(--color-success); } +.graph-panel-card-state.s3 { background: var(--color-error-subtle); color: var(--color-error); } +.graph-panel-card-state.due { background: var(--color-warning-subtle); color: var(--color-warning); } + +.graph-panel-remediation { + color: var(--color-error); + flex-shrink: 0; + display: inline-flex; +} + +/* Track ribbon */ +.graph-track-ribbon { + position: absolute; + left: var(--space-4); + bottom: var(--space-4); + right: auto; + max-width: 560px; + display: flex; + align-items: center; + gap: var(--space-4); + background: var(--color-bg-elevated); + border: 1px solid var(--color-border-accent); + border-radius: var(--radius-lg); + padding: var(--space-3) var(--space-4); + box-shadow: var(--shadow-lg); + z-index: 15; +} + +.graph-track-ribbon-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.graph-track-ribbon-name { + font-weight: 700; + font-size: var(--text-sm); +} + +.graph-track-ribbon-desc { + font-size: var(--text-xs); + color: var(--color-text-secondary); +} + +.graph-track-ribbon-progress { + font-size: 10px; + color: var(--color-text-tertiary); +} + +.graph-track-ribbon-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; +} + +/* In-graph study session overlay */ +.graph-session-overlay { + position: fixed; + inset: 0; + background: var(--color-bg-primary); + z-index: var(--z-overlay); + display: flex; + align-items: center; + justify-content: center; + overflow-y: auto; + padding: var(--space-4); +} + +/* Remediation banner in study sessions */ +.remediation-banner { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + max-width: 560px; + margin-bottom: var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + background: var(--color-error-subtle); + border: 1px solid rgba(248, 113, 113, 0.3); + color: var(--color-error); + font-size: var(--text-xs); + animation: fadeIn var(--duration-normal) ease-out; +} + +.remediation-banner-title { + font-weight: 700; + flex-shrink: 0; +} + +.remediation-banner-reason { + color: var(--color-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ---------- Responsive: calculator + graph ---------- */ +@media (max-width: 1100px) { + .latency-panels { + grid-template-columns: 1fr; + } +} + +@media (max-width: 900px) { + .calc-body { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .calc-page { + padding-bottom: var(--space-12); + } + + .calc-group-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + } + + .graph-header { + padding: var(--space-3); + } + + .graph-node-panel { + width: 100%; + border-left: none; + } + + .graph-track-ribbon { + left: var(--space-2); + right: var(--space-2); + max-width: none; + flex-wrap: wrap; + gap: var(--space-2); + } + + .guide-float-actions { + display: none; + } +} diff --git a/src/pages/BuilderPage.jsx b/src/pages/BuilderPage.jsx index 77ce74c..38af72b 100644 --- a/src/pages/BuilderPage.jsx +++ b/src/pages/BuilderPage.jsx @@ -1,7 +1,8 @@ import { useState, useEffect, useRef, useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' import { MessageSquare, Save, Undo, Redo, ZoomIn, ZoomOut, - MousePointer, Type, ArrowRight, Palette, Layout, Download, + MousePointer, Type, ArrowRight, Palette, Layout, Download, Calculator, } from 'lucide-react' import Toolbox from '../components/builder/Toolbox' import Canvas from '../components/builder/Canvas' @@ -17,6 +18,8 @@ const AUTO_SAVE_DELAY = 3000 export default function BuilderPage() { const toggleChat = useAppStore((s) => s.toggleChat) + const setCalcModalOpen = useAppStore((s) => s.setCalcModalOpen) + const [searchParams] = useSearchParams() const nodes = useAppStore((s) => s.nodes || []) const edges = useAppStore((s) => s.edges || []) const setNodes = useAppStore((s) => s.setNodes) @@ -46,7 +49,10 @@ export default function BuilderPage() { if (cancelled) return if (list && list.length > 0) { setBoards(list) - setActiveBoard(list[0].id) + // Deep link support: /builder?board= (e.g. from the Knowledge Graph) + const requested = searchParams.get('board') + const match = requested && list.find((b) => b.id === requested) + setActiveBoard(match ? match.id : list[0].id) } else { // No saved boards โ€” create a default local board const newBoard = { id: `local-${Date.now()}`, name: 'Untitled Board' } @@ -63,6 +69,8 @@ export default function BuilderPage() { }) return () => { cancelled = true } + // Initial load runs once; the ?board= param only matters on entry. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // โ”€โ”€โ”€ Load board data when activeBoard changes โ”€โ”€โ”€ @@ -484,6 +492,15 @@ export default function BuilderPage() {
+ +
+ + ) + } + + return ( +
+ {/* Header + toolbar */} +
+
+

+ + Knowledge Graph +

+
+ {Object.entries(HEALTH_LABELS).map(([key, label]) => ( + + + {label} {graph.stats[key] ?? 0} + + ))} +
+
+ +
+
+ + setSearch(e.target.value)} + id="graph-search" + /> + {search && ( + + )} +
+ +
+ {READINESS_FILTERS.map((f) => ( + + ))} +
+ + +
+ +
+ {PILLARS.map((p) => ( + + ))} +
+
+ + {/* Canvas + panel */} +
+ + + {selectedId && ( + setSelectedId(null)} + onSelectNode={setSelectedId} + onStartSession={startNodeSession} + /> + )} + + {/* Active track ribbon */} + {activeTrack && ( +
+
+ {activeTrack.emoji} {activeTrack.name} + {activeTrack.description} + + {activeTrack.masteredCount}/{activeTrack.nodeCount} concepts mastered ยท numbered in study order + +
+
+ + +
+
+ )} +
+ + {/* In-graph study session */} + {session && ( +
+ +
+ )} +
+ ) +} diff --git a/src/pages/GuidePage.jsx b/src/pages/GuidePage.jsx index 7436ced..2eeb0c0 100644 --- a/src/pages/GuidePage.jsx +++ b/src/pages/GuidePage.jsx @@ -1,6 +1,6 @@ import { useParams } from 'react-router-dom' -import { MessageSquare } from 'lucide-react' +import { MessageSquare, Calculator } from 'lucide-react' import PillarNav from '../components/guide/PillarNav' import BlueprintShell from '../components/guide/BlueprintShell' import ChatPanel from '../components/shared/ChatPanel' @@ -21,6 +21,7 @@ export default function GuidePage() { const { pillarId } = useParams() const chatOpen = useAppStore((s) => s.chatOpen.guide) const toggleChat = useAppStore((s) => s.toggleChat) + const setCalcModalOpen = useAppStore((s) => s.setCalcModalOpen) const isMobile = useIsMobile() @@ -33,14 +34,28 @@ export default function GuidePage() { {/* Center: Blueprint content (or library overview if no pillar) */}
{/* Floating Ask AI button โ€” only in blueprint view on desktop */} - {!isMobile && !chatOpen && pillarId && ( - + {!isMobile && pillarId && ( +
+ {/* Quick BotE calculator for scaling-estimate sections */} + + {!chatOpen && ( + + )} +
)} diff --git a/src/stores/appStore.js b/src/stores/appStore.js index b2ec7e9..d011f78 100644 --- a/src/stores/appStore.js +++ b/src/stores/appStore.js @@ -128,6 +128,16 @@ const useAppStore = create((set, get) => ({ toasts: s.toasts.filter((t) => t.id !== id), })), + // BotE Calculator quick-access modal (available on every page) + calcModalOpen: false, + setCalcModalOpen: (open) => set({ calcModalOpen: open }), + toggleCalcModal: () => set((s) => ({ calcModalOpen: !s.calcModalOpen })), + + // SRS sync counter โ€” bumps after every card review so open views + // (e.g. the Knowledge Graph heatmap) can refresh without a reload. + srsVersion: 0, + bumpSrsVersion: () => set((s) => ({ srsVersion: s.srsVersion + 1 })), + // Canvas Whiteboard Nodes nodes: [], setNodes: (nodes) => set({ nodes }), diff --git a/src/stores/useCalcStore.js b/src/stores/useCalcStore.js new file mode 100644 index 0000000..dec62dc --- /dev/null +++ b/src/stores/useCalcStore.js @@ -0,0 +1,77 @@ +/** + * @fileoverview BotE Calculator state. + * + * Persisted to localStorage so the sandbox keeps its numbers across + * page navigation and app restarts (spec: cross-app availability). + * The full-page calculator and the quick-access modal share this store, + * so both views always show the same estimate. + */ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import { defaultInputs, sanitizeInput, SCENARIOS } from '../utils/bote' + +const useCalcStore = create( + persist( + (set) => ({ + /** Calculator inputs, keyed by BOTE_INPUT_DEFS keys. */ + inputs: defaultInputs(), + /** The problem being sized โ€” context for presets and the AI audit. */ + scenarioId: 'custom', + /** Latency budget items: [{ id, count }] (ids from LATENCY_NUMBERS). */ + latencyBudget: [], + + /** Set one input (clamped to its definition range). */ + setInput: (key, value) => + set((s) => ({ inputs: { ...s.inputs, [key]: sanitizeInput(key, value) } })), + + /** + * Apply a scenario preset. Preset inputs merge over the current + * ones, so advanced assumptions the preset does not name survive. + */ + applyScenario: (scenarioId) => + set((s) => { + const scenario = SCENARIOS.find((sc) => sc.id === scenarioId) + if (!scenario) return s + return { + scenarioId, + inputs: scenario.inputs ? { ...s.inputs, ...scenario.inputs } : s.inputs, + } + }), + + /** Reset everything to defaults. */ + reset: () => set({ inputs: defaultInputs(), scenarioId: 'custom', latencyBudget: [] }), + + /** Add one unit of a latency constant to the budget composer. */ + addLatencyItem: (id) => + set((s) => { + const existing = s.latencyBudget.find((item) => item.id === id) + if (existing) { + return { + latencyBudget: s.latencyBudget.map((item) => + item.id === id ? { ...item, count: item.count + 1 } : item + ), + } + } + return { latencyBudget: [...s.latencyBudget, { id, count: 1 }] } + }), + + /** Set the multiplier for one budget item; 0 removes it. */ + setLatencyCount: (id, count) => + set((s) => ({ + latencyBudget: + count <= 0 + ? s.latencyBudget.filter((item) => item.id !== id) + : s.latencyBudget.map((item) => (item.id === id ? { ...item, count } : item)), + })), + + /** Empty the latency budget. */ + clearLatencyBudget: () => set({ latencyBudget: [] }), + }), + { + name: 'toolbox_calc_state', + version: 1, + } + ) +) + +export default useCalcStore diff --git a/src/utils/api.js b/src/utils/api.js index b47a779..f2b43fd 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -111,7 +111,7 @@ export const flashcardsApi = { delete: (deckId, cardId) => request(`/decks/${deckId}/cards/${cardId}`, { method: 'DELETE' }), review: (deckId, cardId, quality, confidence) => - request(`/decks/${deckId}/cards/${cardId}/review`, { method: 'POST', body: { quality, confidence } }), + request(`/decks/${deckId}/cards/${cardId}/review`, { method: 'PUT', body: { quality, confidence } }), checkDuplicates: (deckId, cards) => request(`/decks/${deckId}/cards/check-duplicates`, { method: 'POST', body: { cards } }), } @@ -306,3 +306,21 @@ export const systemApi = { export const searchApi = { query: (q) => request(`/search?q=${encodeURIComponent(q)}`), } + +/* ---- Knowledge Graph ---- */ +export const graphApi = { + /** Full graph: nodes with live SRS health, edges, tracks, stats */ + get: () => request('/graph'), + /** Node detail for the slide-over panel */ + getNode: (id) => request(`/graph/nodes/${id}`), + /** Study session built from one node's studyable cards */ + nodeSession: (id) => request(`/graph/nodes/${id}/session`, { method: 'POST' }), + /** Study session for a curated learning track, in prerequisite order */ + trackSession: (id) => request(`/graph/tracks/${id}/session`, { method: 'POST' }), +} + +/* ---- BotE Calculator ---- */ +export const calculatorApi = { + /** AI sanity check. data: { scenario, inputs, results, model } */ + audit: (data) => request('/calculator/audit', { method: 'POST', body: data }), +} diff --git a/src/utils/bote.js b/src/utils/bote.js new file mode 100644 index 0000000..5a81c65 --- /dev/null +++ b/src/utils/bote.js @@ -0,0 +1,521 @@ +/** + * @fileoverview Back-of-the-Envelope (BotE) calculation engine. + * + * Pure functions only โ€” no React, no network, no globals. Every number the + * Calculator page shows comes from `computeEstimates()`. The formulas follow + * the standard interview conventions: + * + * QPS = DAU ร— requests/user/day รท 86,400 + * Peak QPS = average QPS ร— peak multiplier (2โ€“5ร— is typical) + * Storage = writes/day ร— record size ร— retention ร— replication ร— (1 + overhead) + * Cache = working-set % ร— daily read volume (the "80/20 rule") + * Bandwidth = QPS ร— transfer size ร— 8 bits + * Servers = peak QPS รท (QPS per node ร— target utilization) + * + * Unit convention: storage and bandwidth math uses SI decimal units + * (1 KB = 1,000 bytes) โ€” the convention used in capacity estimates. + * The UI shows a binary powers-of-two reference table separately. + */ + +export const SECONDS_PER_DAY = 86_400 +export const DAYS_PER_YEAR = 365 + +/** RAM assumptions for the cache-node estimate (documented in the UI). */ +export const CACHE_NODE_RAM_GB = 64 +export const CACHE_NODE_USABLE_FRACTION = 0.75 + +/** + * Input parameter definitions โ€” single source of truth for the sandbox UI, + * validation, and tests. `min`/`max` clamp user input; `log: true` renders + * a logarithmic slider (for values spanning many orders of magnitude). + */ +export const BOTE_INPUT_DEFS = [ + { + key: 'dau', label: 'Daily Active Users', unit: 'users', + min: 0, max: 2_000_000_000, default: 1_000_000, log: true, logFloor: 1_000, + group: 'traffic', help: 'How many distinct users hit the system per day.', + }, + { + key: 'requestsPerUser', label: 'Requests per User / Day', unit: 'req', + min: 0, max: 10_000, default: 10, log: true, logFloor: 1, + group: 'traffic', help: 'Average reads + writes one user makes in a day.', + }, + { + key: 'readRatio', label: 'Read : Write Ratio', unit: ': 1', + min: 0, max: 10_000, default: 10, log: true, logFloor: 1, + group: 'traffic', help: 'Reads per single write. Most consumer apps sit between 10:1 and 100:1.', + }, + { + key: 'peakMultiplier', label: 'Peak Traffic Multiplier', unit: 'ร—', + min: 1, max: 100, default: 3, + group: 'traffic', help: 'Peak QPS over average QPS. 2โ€“3ร— is a safe default; flash sales can hit 10ร—+.', + }, + { + key: 'payloadKB', label: 'Avg Payload Size', unit: 'KB', + min: 0, max: 100_000, default: 2, log: true, logFloor: 0.1, + group: 'data', help: 'Size of one written record / one read response, without media.', + }, + { + key: 'mediaPercent', label: 'Media / Attachment %', unit: '%', + min: 0, max: 100, default: 10, + group: 'data', help: 'Share of requests that carry a media object (image, clip, file).', + }, + { + key: 'mediaSizeKB', label: 'Avg Media Size', unit: 'KB', + min: 0, max: 1_000_000, default: 500, log: true, logFloor: 1, + group: 'data', help: 'Average size of one media object when present.', + }, + { + key: 'retentionYears', label: 'Data Retention', unit: 'yr', + min: 0, max: 50, default: 5, + group: 'data', help: 'How long written data must stay stored.', + }, + { + key: 'replicationFactor', label: 'Replication Factor', unit: 'ร—', + min: 1, max: 10, default: 3, + group: 'data', help: 'Copies of each byte. 3 is the standard for durability.', + }, + { + key: 'overheadPercent', label: 'Index & Metadata Overhead', unit: '%', + min: 0, max: 200, default: 0, + group: 'advanced', help: 'Extra storage for indexes, metadata, tombstones. Real systems pay 20โ€“40%. Left at 0, the AI audit will call it out.', + }, + { + key: 'cachePercent', label: 'Cached Working Set', unit: '%', + min: 0, max: 100, default: 20, + group: 'advanced', help: 'The 80/20 rule: ~20% of daily read volume serves ~80% of reads.', + }, + { + key: 'qpsPerServer', label: 'Target QPS per Server', unit: 'QPS', + min: 1, max: 1_000_000, default: 1_000, log: true, logFloor: 10, + group: 'advanced', help: 'Sustainable QPS for one app node at full saturation. Commodity nodes: 500โ€“5,000.', + }, + { + key: 'utilizationPercent', label: 'Target CPU Utilization', unit: '%', + min: 1, max: 100, default: 70, + group: 'advanced', help: 'Run servers below 100% so peaks and failovers have headroom.', + }, + { + key: 'storagePerShardTB', label: 'Storage per Shard', unit: 'TB', + min: 0.1, max: 1_000, default: 2, + group: 'advanced', help: 'Practical data volume one database shard should own (keeps rebuilds and backups fast).', + }, +] + +/** Default input object built from the definitions. */ +export function defaultInputs() { + const out = {} + for (const def of BOTE_INPUT_DEFS) out[def.key] = def.default + return out +} + +/** Clamp and sanitize one input value against its definition. */ +export function sanitizeInput(key, value) { + const def = BOTE_INPUT_DEFS.find((d) => d.key === key) + if (!def) return 0 + const num = Number(value) + if (!Number.isFinite(num)) return def.default + return Math.min(def.max, Math.max(def.min, num)) +} + +/** Sanitize a whole input object; missing keys fall back to defaults. */ +export function sanitizeInputs(inputs = {}) { + const out = {} + for (const def of BOTE_INPUT_DEFS) { + out[def.key] = sanitizeInput(def.key, inputs[def.key] ?? def.default) + } + return out +} + +/** + * Compute every estimate from the input set. + * + * @param {Object} rawInputs - Values keyed by BOTE_INPUT_DEFS keys. + * @returns {Object} All derived values in base units (QPS, bytes, bytes/s, + * bits/s, counts). Formatting happens in the UI layer. + */ +export function computeEstimates(rawInputs) { + const inp = sanitizeInputs(rawInputs) + + // โ”€โ”€ Traffic โ”€โ”€ + const requestsPerDay = inp.dau * inp.requestsPerUser + const writeShare = 1 / (inp.readRatio + 1) + const writesPerDay = requestsPerDay * writeShare + const readsPerDay = requestsPerDay - writesPerDay + + const avgWriteQps = writesPerDay / SECONDS_PER_DAY + const avgReadQps = readsPerDay / SECONDS_PER_DAY + const avgTotalQps = requestsPerDay / SECONDS_PER_DAY + const peakWriteQps = avgWriteQps * inp.peakMultiplier + const peakReadQps = avgReadQps * inp.peakMultiplier + const peakTotalQps = avgTotalQps * inp.peakMultiplier + + // โ”€โ”€ Payload sizes (bytes, SI units) โ”€โ”€ + const mediaShare = inp.mediaPercent / 100 + // Average size of one request once the media mix is blended in. + const avgTransferBytes = (inp.payloadKB + mediaShare * inp.mediaSizeKB) * 1e3 + + // โ”€โ”€ Storage โ”€โ”€ + const ingestPerDayBytes = writesPerDay * avgTransferBytes + const ingestRateBytesPerSec = ingestPerDayBytes / SECONDS_PER_DAY + const storagePerYearBytes = ingestPerDayBytes * DAYS_PER_YEAR + const overheadFactor = 1 + inp.overheadPercent / 100 + const rawRetainedBytes = storagePerYearBytes * inp.retentionYears * overheadFactor + const replicatedRetainedBytes = rawRetainedBytes * inp.replicationFactor + // Fixed 5-year figure so users can compare scenarios on equal footing. + const fiveYearReplicatedBytes = + storagePerYearBytes * 5 * overheadFactor * inp.replicationFactor + + // โ”€โ”€ Cache (80/20 working set) โ”€โ”€ + const dailyReadVolumeBytes = readsPerDay * avgTransferBytes + const cacheBytes = dailyReadVolumeBytes * (inp.cachePercent / 100) + const usableRamPerNodeBytes = CACHE_NODE_RAM_GB * 1e9 * CACHE_NODE_USABLE_FRACTION + const cacheNodes = cacheBytes > 0 ? Math.ceil(cacheBytes / usableRamPerNodeBytes) : 0 + + // โ”€โ”€ Bandwidth (bits per second at peak) โ”€โ”€ + const ingressBps = peakWriteQps * avgTransferBytes * 8 + const egressBps = peakReadQps * avgTransferBytes * 8 + + // โ”€โ”€ Hardware โ”€โ”€ + const effectiveQpsPerServer = inp.qpsPerServer * (inp.utilizationPercent / 100) + const appServers = + peakTotalQps > 0 && effectiveQpsPerServer > 0 + ? Math.ceil(peakTotalQps / effectiveQpsPerServer) + : 0 + const shardBytes = inp.storagePerShardTB * 1e12 + const dbShards = + rawRetainedBytes > 0 && shardBytes > 0 ? Math.ceil(rawRetainedBytes / shardBytes) : 0 + + return { + inputs: inp, + requestsPerDay, + writesPerDay, + readsPerDay, + avgWriteQps, + avgReadQps, + avgTotalQps, + peakWriteQps, + peakReadQps, + peakTotalQps, + avgTransferBytes, + ingestPerDayBytes, + ingestRateBytesPerSec, + storagePerYearBytes, + rawRetainedBytes, + replicatedRetainedBytes, + fiveYearReplicatedBytes, + dailyReadVolumeBytes, + cacheBytes, + cacheNodes, + ingressBps, + egressBps, + appServers, + dbShards, + } +} + +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + Formatting โ€” 3 significant figures, unit ladders + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ + +function toPrecision3(value) { + if (value === 0) return '0' + const rounded = Number(value.toPrecision(3)) + // Avoid scientific notation for the magnitudes we show (always < 1000 after scaling) + return rounded >= 100 + ? String(Math.round(rounded)) + : String(rounded) +} + +function scale(value, ladder, baseUnit) { + if (!Number.isFinite(value)) return 'โ€”' + if (value === 0) return `0 ${baseUnit}` + const abs = Math.abs(value) + let chosen = { factor: 1, unit: baseUnit } + for (const step of ladder) { + if (abs >= step.factor) chosen = step + } + return `${toPrecision3(value / chosen.factor)} ${chosen.unit}` +} + +const COUNT_LADDER = [ + { factor: 1e3, unit: 'K' }, + { factor: 1e6, unit: 'M' }, + { factor: 1e9, unit: 'B' }, + { factor: 1e12, unit: 'T' }, +] + +const BYTE_LADDER = [ + { factor: 1e3, unit: 'KB' }, + { factor: 1e6, unit: 'MB' }, + { factor: 1e9, unit: 'GB' }, + { factor: 1e12, unit: 'TB' }, + { factor: 1e15, unit: 'PB' }, + { factor: 1e18, unit: 'EB' }, +] + +const BITRATE_LADDER = [ + { factor: 1e3, unit: 'Kbps' }, + { factor: 1e6, unit: 'Mbps' }, + { factor: 1e9, unit: 'Gbps' }, + { factor: 1e12, unit: 'Tbps' }, +] + +/** 12345678 โ†’ "12.3M" (no unit suffix beyond the magnitude letter). */ +export function formatCount(value) { + if (!Number.isFinite(value)) return 'โ€”' + if (value === 0) return '0' + const abs = Math.abs(value) + if (abs < 1e3) return toPrecision3(value) + let chosen = COUNT_LADDER[0] + for (const step of COUNT_LADDER) { + if (abs >= step.factor) chosen = step + } + return `${toPrecision3(value / chosen.factor)}${chosen.unit}` +} + +/** Bytes โ†’ "1.23 TB" (SI decimal units). */ +export function formatBytes(value) { + return scale(value, BYTE_LADDER, 'B') +} + +/** Bytes/second โ†’ "12.3 MB/s". */ +export function formatBytesPerSec(value) { + const text = scale(value, BYTE_LADDER, 'B') + return text === 'โ€”' ? text : `${text}/s` +} + +/** Bits/second โ†’ "1.52 Gbps". */ +export function formatBitrate(value) { + return scale(value, BITRATE_LADDER, 'bps') +} + +/** QPS โ†’ "1.16K QPS". */ +export function formatQps(value) { + if (!Number.isFinite(value)) return 'โ€”' + return `${formatCount(value)} QPS` +} + +/** Nanoseconds โ†’ human latency string ("0.5 ns", "10 ยตs", "150 ms", "1.2 s"). */ +export function formatLatency(ns) { + if (!Number.isFinite(ns)) return 'โ€”' + if (ns === 0) return '0 ns' + if (ns < 1e3) return `${toPrecision3(ns)} ns` + if (ns < 1e6) return `${toPrecision3(ns / 1e3)} ยตs` + if (ns < 1e9) return `${toPrecision3(ns / 1e6)} ms` + return `${toPrecision3(ns / 1e9)} s` +} + +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + Latency reference โ€” "Numbers Every Engineer Should Know" + Canonical Dean/Norvig table plus modern network figures. + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ + +export const LATENCY_NUMBERS = [ + { id: 'l1-cache', label: 'L1 cache reference', ns: 0.5, tier: 'cpu' }, + { id: 'branch-mispredict', label: 'Branch mispredict', ns: 5, tier: 'cpu' }, + { id: 'l2-cache', label: 'L2 cache reference', ns: 7, tier: 'cpu' }, + { id: 'mutex', label: 'Mutex lock / unlock', ns: 25, tier: 'cpu' }, + { id: 'ram-ref', label: 'Main memory (RAM) reference', ns: 100, tier: 'memory' }, + { id: 'compress-1k', label: 'Compress 1 KB (Snappy)', ns: 3_000, tier: 'cpu' }, + { id: 'send-1k-net', label: 'Send 1 KB over 1 Gbps network', ns: 10_000, tier: 'network' }, + { id: 'nvme-read-4k', label: 'NVMe SSD random read (4 KB)', ns: 10_000, tier: 'disk' }, + { id: 'ram-read-1mb', label: 'Read 1 MB sequentially from RAM', ns: 250_000, tier: 'memory' }, + { id: 'dc-rtt', label: 'Round trip inside one datacenter', ns: 500_000, tier: 'network' }, + { id: 'ssd-read-1mb', label: 'Read 1 MB sequentially from SSD', ns: 1_000_000, tier: 'disk' }, + { id: 'az-rtt', label: 'Cross-AZ round trip (same region)', ns: 1_000_000, tier: 'network' }, + { id: 'hdd-seek', label: 'HDD disk seek', ns: 10_000_000, tier: 'disk' }, + { id: 'hdd-read-1mb', label: 'Read 1 MB sequentially from HDD', ns: 20_000_000, tier: 'disk' }, + { id: 'region-rtt', label: 'Cross-region RTT (US East โ†” US West)', ns: 65_000_000, tier: 'network' }, + { id: 'continent-rtt', label: 'Cross-continent RTT (CA โ†” Europe)', ns: 150_000_000, tier: 'network' }, +] + +/** Powers-of-two / handy constants table shown next to the latency sheet. */ +export const POWERS_OF_TWO = [ + { power: 10, approx: '~1 thousand', exact: '1,024', unit: '1 KB' }, + { power: 20, approx: '~1 million', exact: '1,048,576', unit: '1 MB' }, + { power: 30, approx: '~1 billion', exact: '1.07 ร— 10โน', unit: '1 GB' }, + { power: 40, approx: '~1 trillion', exact: '1.10 ร— 10ยนยฒ', unit: '1 TB' }, + { power: 50, approx: '~1 quadrillion', exact: '1.13 ร— 10ยนโต', unit: '1 PB' }, +] + +export const HANDY_CONSTANTS = [ + { label: 'Seconds per day', value: '86,400 โ‰ˆ 10โต' }, + { label: 'Seconds per month', value: '~2.6 million' }, + { label: 'Requests/day per 1 avg QPS', value: '86,400' }, + { label: '1M requests/day', value: 'โ‰ˆ 11.6 QPS average' }, + { label: 'Days per year', value: '365 โ‰ˆ 3.15 ร— 10โท s' }, +] + +/** + * Sum a latency budget (list of { id, count }) into total nanoseconds. + * Unknown ids are ignored so stale saved budgets never crash. + */ +export function sumLatencyBudget(items = []) { + let total = 0 + for (const item of items) { + const ref = LATENCY_NUMBERS.find((l) => l.id === item.id) + if (ref) total += ref.ns * Math.max(0, item.count || 0) + } + return total +} + +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + Scenario presets + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ + +export const SCENARIOS = [ + { + id: 'custom', + name: 'Custom', + emoji: '๐ŸŽ›๏ธ', + description: 'Your own numbers, no preset assumptions.', + }, + { + id: 'url-shortener', + name: 'URL Shortener', + emoji: '๐Ÿ”—', + description: 'Tiny payloads, extreme read skew, long retention.', + inputs: { + dau: 10_000_000, requestsPerUser: 2, readRatio: 100, peakMultiplier: 2, + payloadKB: 0.5, mediaPercent: 0, mediaSizeKB: 0, retentionYears: 10, + replicationFactor: 3, cachePercent: 20, + }, + }, + { + id: 'video-streaming', + name: 'Video Streaming', + emoji: '๐ŸŽฌ', + description: 'Huge media objects, CDN-dominated egress.', + inputs: { + dau: 50_000_000, requestsPerUser: 8, readRatio: 200, peakMultiplier: 2, + payloadKB: 5, mediaPercent: 80, mediaSizeKB: 300_000, retentionYears: 5, + replicationFactor: 3, cachePercent: 10, + }, + }, + { + id: 'flash-sale', + name: 'E-Commerce Flash Sale', + emoji: 'โšก', + description: 'Moderate scale but brutal peak multiplier and write contention.', + inputs: { + dau: 5_000_000, requestsPerUser: 20, readRatio: 20, peakMultiplier: 30, + payloadKB: 4, mediaPercent: 30, mediaSizeKB: 200, retentionYears: 3, + replicationFactor: 3, cachePercent: 30, + }, + }, + { + id: 'chat-app', + name: 'Chat / Messaging', + emoji: '๐Ÿ’ฌ', + description: 'Write-heavy for consumer apps, fan-out on delivery.', + inputs: { + dau: 20_000_000, requestsPerUser: 40, readRatio: 4, peakMultiplier: 3, + payloadKB: 1, mediaPercent: 5, mediaSizeKB: 800, retentionYears: 5, + replicationFactor: 3, cachePercent: 20, + }, + }, + { + id: 'social-feed', + name: 'Social Feed', + emoji: '๐Ÿ“ฑ', + description: 'Classic 100:1 read skew with mixed media.', + inputs: { + dau: 100_000_000, requestsPerUser: 30, readRatio: 100, peakMultiplier: 3, + payloadKB: 2, mediaPercent: 20, mediaSizeKB: 400, retentionYears: 5, + replicationFactor: 3, cachePercent: 20, + }, + }, + { + id: 'ride-sharing', + name: 'Ride Sharing', + emoji: '๐Ÿš—', + description: 'Constant location writes โ€” near 1:1 read/write ratio.', + inputs: { + dau: 8_000_000, requestsPerUser: 120, readRatio: 1, peakMultiplier: 4, + payloadKB: 0.3, mediaPercent: 0, mediaSizeKB: 0, retentionYears: 1, + replicationFactor: 3, cachePercent: 40, + }, + }, +] + +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + Markdown export + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ + +/** + * Build the exportable Markdown summary (with LaTeX formulas). + * + * @param {Object} results - Return of computeEstimates() + * @param {Object} [options] + * @param {string} [options.scenarioName] - Selected scenario label + * @param {Array} [options.latencyBudget] - Budget items [{ id, count }] + * @returns {string} Markdown document + */ +export function buildMarkdownSummary(results, { scenarioName, latencyBudget } = {}) { + const i = results.inputs + const lines = [] + lines.push(`## Back-of-the-Envelope Estimate${scenarioName && scenarioName !== 'Custom' ? ` โ€” ${scenarioName}` : ''}`) + lines.push('') + lines.push('### Assumptions') + lines.push('') + lines.push('| Parameter | Value |') + lines.push('|---|---|') + lines.push(`| Daily Active Users | ${formatCount(i.dau)} |`) + lines.push(`| Requests per user / day | ${formatCount(i.requestsPerUser)} |`) + lines.push(`| Read : Write ratio | ${formatCount(i.readRatio)} : 1 |`) + lines.push(`| Peak multiplier | ${i.peakMultiplier}ร— |`) + lines.push(`| Avg payload | ${i.payloadKB} KB (+${i.mediaPercent}% media @ ${formatBytes(i.mediaSizeKB * 1e3)}) |`) + lines.push(`| Retention ร— replication | ${i.retentionYears} yr ร— ${i.replicationFactor}ร— |`) + if (i.overheadPercent > 0) lines.push(`| Index/metadata overhead | ${i.overheadPercent}% |`) + lines.push('') + lines.push('### Traffic') + lines.push('') + lines.push('$$QPS_{write} = \\frac{DAU \\times req/user}{86{,}400 \\times (R+1)}$$') + lines.push('') + lines.push(`- Average write QPS: **${formatCount(results.avgWriteQps)}**`) + lines.push(`- Average read QPS: **${formatCount(results.avgReadQps)}**`) + lines.push(`- Peak total QPS (${i.peakMultiplier}ร—): **${formatCount(results.peakTotalQps)}**`) + lines.push('') + lines.push('### Storage') + lines.push('') + lines.push('$$S = W_{day} \\times size \\times 365 \\times years \\times RF \\times (1 + overhead)$$') + lines.push('') + lines.push(`- Ingestion rate: **${formatBytesPerSec(results.ingestRateBytesPerSec)}** (${formatBytes(results.ingestPerDayBytes)}/day)`) + lines.push(`- Storage per year (raw): **${formatBytes(results.storagePerYearBytes)}**`) + lines.push(`- ${i.retentionYears}-year total ร— ${i.replicationFactor} replicas: **${formatBytes(results.replicatedRetainedBytes)}**`) + lines.push('') + lines.push('### Cache & Memory') + lines.push('') + lines.push(`$$Cache = ${i.cachePercent}\\% \\times reads_{day} \\times size$$`) + lines.push('') + lines.push(`- Working-set cache: **${formatBytes(results.cacheBytes)}** (~${results.cacheNodes} ร— ${CACHE_NODE_RAM_GB} GB nodes)`) + lines.push('') + lines.push('### Bandwidth (at peak)') + lines.push('') + lines.push(`- Ingress: **${formatBitrate(results.ingressBps)}**`) + lines.push(`- Egress: **${formatBitrate(results.egressBps)}**`) + lines.push('') + lines.push('### Hardware') + lines.push('') + lines.push(`$$N = \\lceil QPS_{peak} \\div (QPS_{node} \\times ${i.utilizationPercent}\\%) \\rceil$$`) + lines.push('') + lines.push(`- App servers: **~${formatCount(results.appServers)}** (${formatCount(i.qpsPerServer)} QPS/node @ ${i.utilizationPercent}%)`) + lines.push(`- DB shards: **~${formatCount(results.dbShards)}** (${i.storagePerShardTB} TB/shard)`) + + if (latencyBudget && latencyBudget.length > 0) { + lines.push('') + lines.push('### Latency Budget') + lines.push('') + for (const item of latencyBudget) { + const ref = LATENCY_NUMBERS.find((l) => l.id === item.id) + if (!ref) continue + lines.push(`- ${item.count} ร— ${ref.label} = ${formatLatency(ref.ns * item.count)}`) + } + lines.push(`- **Total โ‰ˆ ${formatLatency(sumLatencyBudget(latencyBudget))}**`) + } + + lines.push('') + lines.push('> Generated with the Toolbox BotE Calculator') + return lines.join('\n') +} diff --git a/src/utils/constants.js b/src/utils/constants.js index d0514c5..5ff64e4 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -34,6 +34,7 @@ export const PILLARS = [ { id: 'relational-oltp', name: 'Relational Databases (OLTP)' }, { id: 'analytical-olap', name: 'Analytical Databases (OLAP)' }, { id: 'kv-stores', name: 'Key-Value / In-Memory Stores' }, + { id: 'caching-strategies', name: 'Caching Strategies & Invalidation' }, { id: 'object-blob-storage', name: 'Object & Blob Storage' }, { id: 'full-text-search', name: 'Full-Text Search (Inverted Indexes)' }, { id: 'vector-indexes', name: 'Vector & Semantic Indexes' }, @@ -93,6 +94,7 @@ export const PILLARS = [ { id: 'partitioning-sharding', name: 'Partitioning & Sharding' }, { id: 'replication-strategies', name: 'Replication Strategies' }, { id: 'consistency-models', name: 'Consistency Models (CAP/PACELC)' }, + { id: 'consensus-coordination', name: 'Consensus & Coordination' }, ], }, { diff --git a/src/utils/knowledgeGraph.js b/src/utils/knowledgeGraph.js new file mode 100644 index 0000000..81c8ab2 --- /dev/null +++ b/src/utils/knowledgeGraph.js @@ -0,0 +1,765 @@ +/** + * @fileoverview The system design Knowledge Graph. + * + * Nodes are concepts. Edges are directed prerequisite dependencies: + * an edge `{ from: A, to: B }` means "learn A before B". + * + * The ordering follows the standard learning progression used by the + * major interview-prep curricula (DDIA chapter order, roadmap-style + * fundamentals โ†’ building blocks โ†’ distributed theory): + * 1. Fundamentals: client-server, HTTP, SQL, hashing, latency math + * 2. Building blocks: load balancing, caching, indexes, queues, CDN + * 3. Distributed mechanics: replication, partitioning, CAP, quorums + * 4. Advanced: consensus, distributed KV stores, transactions + * 5. Paradigms: full architectures that compose everything below + * + * This module is pure data + pure functions. It is imported by BOTH the + * client (graph visualizer) and the server (health + remediation engine), + * so keep it free of React and Node dependencies. + * + * Every node: + * id - stable slug (used in URLs and the remediation queue) + * name - display name + * pillarId - one of the 7 pillars in constants.js (grouping + color) + * topicId - guide topic for deep-linking (null = graph-only concept) + * keywords - lowercase phrases that link flashcards to this node. + * Multi-word phrases preferred โ€” they keep matching precise. + * components- builder component ids, to find related whiteboards + * summary - one-liner shown in the node panel + */ + +export const GRAPH_NODES = [ + // โ”€โ”€ Fundamentals โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + { + id: 'client-server', name: 'Client-Server Model', pillarId: 'network-protocols', + topicId: 'request-response', + keywords: ['client-server', 'client server model', 'request-response', 'request response cycle'], + components: ['web-client', 'mobile-client'], + summary: 'Clients send requests; servers answer them. The base pattern every other concept builds on.', + }, + { + id: 'latency-throughput', name: 'Latency vs Throughput', pillarId: 'distributed-mechanics', + topicId: null, + keywords: ['latency vs throughput', 'p99', 'p95', 'tail latency', 'percentile latency', 'throughput'], + components: [], + summary: 'Latency is time per request; throughput is requests per time. Optimizing one often costs the other.', + }, + { + id: 'capacity-estimation', name: 'Capacity Estimation (BotE)', pillarId: 'distributed-mechanics', + topicId: null, + keywords: ['back of the envelope', 'capacity estimation', 'qps estimate', 'estimation'], + components: [], + summary: 'Sizing traffic, storage, cache, and hardware from first principles. Practice in the Calculator.', + }, + { + id: 'scalability-basics', name: 'Vertical vs Horizontal Scaling', pillarId: 'compute', + topicId: 'stateless-compute', + keywords: ['horizontal scaling', 'vertical scaling', 'scale out', 'scale up'], + components: [], + summary: 'Bigger machines vs more machines โ€” and why the web picked "more machines".', + }, + { + id: 'availability-slos', name: 'Availability, SLOs & Nines', pillarId: 'resiliency', + topicId: null, + keywords: ['availability', 'sla', 'slo', 'five nines', 'error budget', 'uptime'], + components: [], + summary: 'How reliability is measured (99.9% vs 99.999%) and promised (SLAs, error budgets).', + }, + { + id: 'http-rest', name: 'HTTP & REST APIs', pillarId: 'network-protocols', + topicId: 'request-response', + keywords: ['http', 'rest api', 'restful', 'status code', 'idempotent method'], + components: [], + summary: 'The lingua franca of services: verbs, status codes, headers, statelessness.', + }, + { + id: 'dns', name: 'DNS & Service Discovery', pillarId: 'network-protocols', + topicId: 'request-response', + keywords: ['dns', 'domain name system', 'service discovery', 'name resolution'], + components: [], + summary: 'Turning names into addresses โ€” the first hop of every request.', + }, + { + id: 'sql-basics', name: 'Relational Modeling & SQL', pillarId: 'data-storage', + topicId: 'relational-oltp', + keywords: ['relational database', 'sql', 'normalization', 'foreign key', 'schema design', 'postgres', 'mysql'], + components: ['sql-db'], + summary: 'Tables, joins, and normalization โ€” the default data home until scale forces trade-offs.', + }, + { + id: 'hashing-fundamentals', name: 'Hash Functions & Key Distribution', pillarId: 'distributed-mechanics', + topicId: 'partitioning-sharding', + keywords: ['hash function', 'hash key', 'modulo hashing', 'uniform distribution'], + components: [], + summary: 'Deterministic key โ†’ bucket mapping. The primitive behind sharding and consistent hashing.', + }, + + // โ”€โ”€ Building blocks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + { + id: 'load-balancing', name: 'Load Balancing', pillarId: 'compute', + topicId: 'traffic-gateways', + keywords: ['load balancer', 'load balancing', 'round robin', 'least connections', 'l4 vs l7', 'health check'], + components: ['load-balancer'], + summary: 'Spreading traffic across replicas: algorithms, health checks, L4 vs L7.', + }, + { + id: 'reverse-proxy', name: 'Reverse Proxies & API Gateways', pillarId: 'compute', + topicId: 'traffic-gateways', + keywords: ['reverse proxy', 'api gateway', 'forward proxy', 'tls termination', 'ingress'], + components: ['api-gateway'], + summary: 'The front door: routing, TLS termination, auth, and cross-cutting policies.', + }, + { + id: 'stateless-services', name: 'Stateless Services & Sessions', pillarId: 'compute', + topicId: 'stateless-compute', + keywords: ['stateless service', 'session affinity', 'sticky session', 'externalized state', 'microservice'], + components: ['microservice'], + summary: 'Push state out of app servers so any replica can serve any request.', + }, + { + id: 'serverless', name: 'Serverless & FaaS', pillarId: 'compute', + topicId: 'stateless-compute', + keywords: ['serverless', 'lambda', 'faas', 'cold start'], + components: ['serverless-fn'], + summary: 'Functions that scale to zero โ€” and the cold-start / state trade-offs that come with them.', + }, + { + id: 'db-indexing', name: 'Database Indexing & B-Trees', pillarId: 'data-storage', + topicId: 'relational-oltp', + keywords: ['b-tree', 'btree', 'database index', 'composite index', 'covering index', 'index scan'], + components: [], + summary: 'Why reads get fast and writes pay for it: B-trees, composite keys, covering indexes.', + }, + { + id: 'transactions-acid', name: 'Transactions & ACID', pillarId: 'data-storage', + topicId: 'relational-oltp', + keywords: ['acid', 'transaction isolation', 'serializable', 'two-phase locking', 'mvcc', 'write skew'], + components: [], + summary: 'Atomicity and isolation levels โ€” what the database really guarantees.', + }, + { + id: 'caching-fundamentals', name: 'Caching Fundamentals', pillarId: 'data-storage', + topicId: 'caching-strategies', + keywords: ['cache hit', 'cache miss', 'hit ratio', 'working set', '80/20 rule', 'hot key'], + components: ['cache'], + summary: 'The 80/20 working set: serve hot data from memory, protect the database.', + }, + { + id: 'cache-strategies', name: 'Cache Writing & Eviction', pillarId: 'data-storage', + topicId: 'caching-strategies', + keywords: ['cache-aside', 'cache aside', 'write-through', 'write-behind', 'write-back', 'lru', 'lfu', 'ttl eviction'], + components: ['cache'], + summary: 'Cache-aside vs write-through vs write-behind, plus LRU/LFU eviction.', + }, + { + id: 'cache-invalidation', name: 'Cache Invalidation & Stampedes', pillarId: 'data-storage', + topicId: 'caching-strategies', + keywords: ['cache invalidation', 'cache stampede', 'thundering herd', 'stale cache', 'cache coherence', 'dogpile'], + components: ['cache'], + summary: 'One of the two hard problems: staleness, stampedes, and the thundering herd.', + }, + { + id: 'cdn', name: 'CDN & Edge Caching', pillarId: 'compute', + topicId: 'edge-cdn', + keywords: ['cdn', 'edge cache', 'content delivery network', 'point of presence', 'edge node', 'cache-control'], + components: ['cdn'], + summary: 'Push static and cacheable content to the edge, next to users.', + }, + { + id: 'object-storage', name: 'Object & Blob Storage', pillarId: 'data-storage', + topicId: 'object-blob-storage', + keywords: ['object storage', 'blob storage', 's3', 'presigned url', 'multipart upload'], + components: ['object-storage'], + summary: 'Cheap, durable, flat-namespace storage for media and backups.', + }, + { + id: 'nosql-types', name: 'NoSQL Data Models', pillarId: 'data-storage', + topicId: 'kv-stores', + keywords: ['nosql', 'document store', 'wide column', 'key-value store', 'denormalization', 'mongodb', 'cassandra'], + components: ['nosql-db'], + summary: 'Key-value, document, wide-column, graph โ€” trading query power for scale.', + }, + { + id: 'message-queues', name: 'Message Queues & Async Work', pillarId: 'compute', + topicId: 'message-brokers', + keywords: ['message queue', 'task queue', 'dead letter', 'at-least-once', 'consumer group', 'sqs', 'rabbitmq'], + components: ['message-queue', 'worker'], + summary: 'Decouple producers from consumers; absorb bursts; retry safely.', + }, + { + id: 'pubsub-logs', name: 'Pub/Sub & Event Logs', pillarId: 'compute', + topicId: 'message-brokers', + keywords: ['pub/sub', 'pubsub', 'kafka', 'event log', 'log compaction', 'partition offset', 'event-driven'], + components: ['event-bus'], + summary: 'Durable, replayable event streams (Kafka-style) vs fire-and-forget queues.', + }, + { + id: 'websockets-streaming', name: 'WebSockets & Realtime Push', pillarId: 'network-protocols', + topicId: 'streaming', + keywords: ['websocket', 'long polling', 'server-sent events', 'sse', 'realtime push', 'duplex'], + components: [], + summary: 'Keeping a connection open: long polling โ†’ SSE โ†’ WebSockets.', + }, + { + id: 'grpc-serialization', name: 'gRPC & Binary Serialization', pillarId: 'network-protocols', + topicId: 'binary-serialization', + keywords: ['grpc', 'protobuf', 'protocol buffers', 'avro', 'thrift', 'schema evolution', 'binary serialization'], + components: [], + summary: 'Compact typed wire formats and schema evolution for service-to-service calls.', + }, + { + id: 'agentic-tools', name: 'Agentic Tool Contracts', pillarId: 'network-protocols', + topicId: 'agentic-contracts', + keywords: ['tool calling', 'function calling', 'mcp', 'agent contract', 'tool schema'], + components: [], + summary: 'Typed contracts that let LLM agents call systems safely.', + }, + { + id: 'rate-limiting', name: 'Rate Limiting Algorithms', pillarId: 'resiliency', + topicId: 'rate-limiters-load-shedding', + keywords: ['rate limit', 'token bucket', 'leaky bucket', 'sliding window', 'throttling', '429'], + components: ['rate-limiter'], + summary: 'Token bucket, leaky bucket, sliding windows โ€” protecting systems from abuse and bursts.', + }, + { + id: 'timeouts-retries', name: 'Timeouts, Retries & Backoff', pillarId: 'resiliency', + topicId: 'retries-backoff', + keywords: ['exponential backoff', 'retry storm', 'jitter', 'timeout budget', 'deadline propagation'], + components: ['retry-handler'], + summary: 'Exponential backoff with jitter โ€” and why naive retries melt systems.', + }, + { + id: 'circuit-breakers', name: 'Circuit Breakers & Bulkheads', pillarId: 'resiliency', + topicId: 'circuit-breakers', + keywords: ['circuit breaker', 'bulkhead', 'half-open', 'fail fast', 'cascading failure'], + components: ['circuit-breaker'], + summary: 'Fail fast when a dependency is down; contain the blast radius.', + }, + { + id: 'load-shedding', name: 'Load Shedding & Backpressure', pillarId: 'resiliency', + topicId: 'rate-limiters-load-shedding', + keywords: ['load shedding', 'backpressure', 'admission control', 'graceful degradation', 'priority queue drop'], + components: ['load-shedder'], + summary: 'When overloaded, drop cheap work early instead of failing everything late.', + }, + { + id: 'idempotency', name: 'Idempotency & Delivery Semantics', pillarId: 'distributed-mechanics', + topicId: null, + keywords: ['idempotency key', 'idempotent', 'exactly-once', 'at-least-once delivery', 'deduplication'], + components: [], + summary: 'Making retries safe: idempotency keys and the myth of exactly-once.', + }, + { + id: 'metrics-logs', name: 'Metrics, Logs & Alerting', pillarId: 'observability', + topicId: 'telemetry', + keywords: ['metrics', 'structured logging', 'alerting', 'golden signals', 'prometheus', 'log aggregation'], + components: ['logger', 'metrics'], + summary: 'The golden signals: latency, traffic, errors, saturation.', + }, + { + id: 'distributed-tracing', name: 'Distributed Tracing', pillarId: 'observability', + topicId: 'telemetry', + keywords: ['distributed tracing', 'trace id', 'span', 'opentelemetry', 'correlation id'], + components: ['tracer'], + summary: 'Following one request across a dozen services with trace/span IDs.', + }, + { + id: 'full-text-search', name: 'Inverted Indexes & Search', pillarId: 'data-storage', + topicId: 'full-text-search', + keywords: ['inverted index', 'full-text search', 'elasticsearch', 'tokenization', 'tf-idf', 'relevance scoring'], + components: ['search-index'], + summary: 'Term โ†’ documents mapping that powers search engines.', + }, + { + id: 'vector-search', name: 'Vector & Semantic Search', pillarId: 'data-storage', + topicId: 'vector-indexes', + keywords: ['vector database', 'embedding', 'ann', 'hnsw', 'semantic search', 'similarity search'], + components: ['vector-db'], + summary: 'Embeddings + approximate nearest neighbor indexes (HNSW) for meaning-based retrieval.', + }, + { + id: 'lsm-trees', name: 'LSM Trees & Write-Optimized Storage', pillarId: 'data-storage', + topicId: 'state-engines', + keywords: ['lsm tree', 'lsm-tree', 'sstable', 'memtable', 'compaction', 'write amplification', 'rocksdb'], + components: [], + summary: 'Memtables + SSTables + compaction: how write-heavy stores beat B-trees.', + }, + { + id: 'olap-warehousing', name: 'OLAP & Columnar Warehouses', pillarId: 'data-storage', + topicId: 'analytical-olap', + keywords: ['olap', 'columnar storage', 'data warehouse', 'star schema', 'column-oriented'], + components: [], + summary: 'Column-oriented storage for scanning billions of rows analytically.', + }, + + // โ”€โ”€ Distributed data mechanics โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + { + id: 'replication', name: 'Replication (Leader / Follower)', pillarId: 'distributed-mechanics', + topicId: 'replication-strategies', + keywords: ['leader follower', 'primary replica', 'single leader replication', 'multi-leader', 'failover replica', 'read replica', 'replication'], + components: [], + summary: 'Copies of data for availability and read scale โ€” leader-based, multi-leader, leaderless.', + }, + { + id: 'replication-lag', name: 'Replication Lag & Read-Your-Writes', pillarId: 'distributed-mechanics', + topicId: 'replication-strategies', + keywords: ['replication lag', 'read-your-writes', 'read your own writes', 'monotonic reads', 'eventual consistency'], + components: [], + summary: 'Followers fall behind. What stale reads break, and the guarantees that fix them.', + }, + { + id: 'partitioning', name: 'Partitioning & Sharding', pillarId: 'distributed-mechanics', + topicId: 'partitioning-sharding', + keywords: ['sharding', 'partition key', 'range partitioning', 'hash partitioning', 'hot partition', 'shard rebalancing', 'skew'], + components: [], + summary: 'Splitting data across nodes: range vs hash, hot spots, rebalancing.', + }, + { + id: 'consistent-hashing', name: 'Consistent Hashing', pillarId: 'distributed-mechanics', + topicId: 'partitioning-sharding', + keywords: ['consistent hashing', 'hash ring', 'ring position', 'minimal reshuffling'], + components: [], + summary: 'The hash ring: adding a node only remaps the arc it owns, not every key.', + }, + { + id: 'virtual-nodes', name: 'Virtual Nodes & Rebalancing', pillarId: 'distributed-mechanics', + topicId: 'partitioning-sharding', + keywords: ['virtual node', 'vnode', 'virtual nodes', 'ring rebalancing'], + components: [], + summary: 'Many small ring positions per physical node โ€” smooth load, faster rebalancing.', + }, + { + id: 'cap-theorem', name: 'CAP & PACELC', pillarId: 'distributed-mechanics', + topicId: 'consistency-models', + keywords: ['cap theorem', 'pacelc', 'partition tolerance', 'cp vs ap'], + components: [], + summary: 'Under a network partition you pick consistency or availability. PACELC adds the latency trade.', + }, + { + id: 'consistency-models', name: 'Consistency Models', pillarId: 'distributed-mechanics', + topicId: 'consistency-models', + keywords: ['linearizability', 'sequential consistency', 'causal consistency', 'strong consistency', 'consistency model'], + components: [], + summary: 'The spectrum from linearizable to eventual โ€” and what each one costs.', + }, + { + id: 'quorums', name: 'Quorum Reads & Writes', pillarId: 'distributed-mechanics', + topicId: 'replication-strategies', + keywords: ['quorum', 'w + r > n', 'sloppy quorum', 'hinted handoff', 'read repair'], + components: [], + summary: 'W + R > N: overlapping read/write sets so someone always has the latest value.', + }, + { + id: 'failure-detection', name: 'Failure Detection & Gossip', pillarId: 'distributed-mechanics', + topicId: 'consensus-coordination', + keywords: ['heartbeat', 'gossip protocol', 'failure detection', 'phi accrual', 'membership'], + components: [], + summary: 'Heartbeats and gossip: how a cluster learns a node is gone.', + }, + { + id: 'leader-election', name: 'Leader Election & Failover', pillarId: 'distributed-mechanics', + topicId: 'consensus-coordination', + keywords: ['leader election', 'failover', 'split brain', 'fencing token'], + components: [], + summary: 'Choosing exactly one leader โ€” and surviving split brain when the network lies.', + }, + { + id: 'consensus', name: 'Consensus (Raft / Paxos)', pillarId: 'distributed-mechanics', + topicId: 'consensus-coordination', + keywords: ['raft', 'paxos', 'consensus algorithm', 'log replication', 'term election'], + components: [], + summary: 'Getting machines to agree on a value despite failures โ€” the hardest primitive.', + }, + { + id: 'coordination-services', name: 'Coordination Services', pillarId: 'distributed-mechanics', + topicId: 'consensus-coordination', + keywords: ['zookeeper', 'etcd', 'distributed lock', 'coordination service', 'lease'], + components: [], + summary: 'ZooKeeper/etcd: consensus packaged as locks, leases, and configuration.', + }, + { + id: 'distributed-kv', name: 'Distributed KV Stores (Dynamo)', pillarId: 'data-storage', + topicId: 'kv-stores', + keywords: ['dynamo', 'distributed key-value', 'vector clock', 'merkle tree', 'anti-entropy', 'riak'], + components: ['nosql-db', 'cache'], + summary: 'Dynamo-style stores: consistent hashing + quorums + gossip, assembled.', + }, + { + id: 'distributed-transactions', name: 'Distributed Transactions & Sagas', pillarId: 'distributed-mechanics', + topicId: 'consistency-models', + keywords: ['two-phase commit', '2pc', 'saga pattern', 'saga', 'outbox pattern', 'compensating transaction'], + components: [], + summary: '2PC, sagas, and the outbox pattern โ€” atomicity across services.', + }, + { + id: 'stream-processing', name: 'Stream Processing & Windowing', pillarId: 'compute', + topicId: 'stream-processors', + keywords: ['stream processing', 'windowing', 'watermark', 'flink', 'exactly-once processing', 'stateful stream'], + components: ['stream-processor'], + summary: 'Continuous computation over event streams: windows, watermarks, state.', + }, + { + id: 'batch-processing', name: 'Batch Processing & MapReduce', pillarId: 'compute', + topicId: 'batch-processing', + keywords: ['mapreduce', 'batch job', 'spark', 'etl pipeline', 'dataflow'], + components: ['batch-processor'], + summary: 'Throughput-optimized offline computation over huge datasets.', + }, + { + id: 'hitl-gateways', name: 'Human-in-the-Loop Gateways', pillarId: 'observability', + topicId: 'hitl-gateways', + keywords: ['human in the loop', 'approval queue', 'review queue', 'escalation'], + components: [], + summary: 'Routing low-confidence automated decisions to humans without stalling the pipeline.', + }, + { + id: 'eval-frameworks', name: 'Evaluation Frameworks', pillarId: 'observability', + topicId: 'eval-frameworks', + keywords: ['eval framework', 'golden dataset', 'llm evaluation', 'regression eval', 'a/b test'], + components: [], + summary: 'Measuring quality of AI/ML behavior continuously, not anecdotally.', + }, + + // โ”€โ”€ Architectural paradigms (capstones) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + { + id: 'heavy-write-pipeline', name: 'Heavy-Write Ingestion Pipeline', pillarId: 'paradigms', + topicId: 'heavy-write', + keywords: ['write-heavy', 'ingestion pipeline', 'firehose', 'buffered writes', 'write path'], + components: ['event-bus', 'stream-processor', 'load-shedder'], + summary: 'Absorb a firehose: buffer in a log, shed load early, write to LSM storage.', + }, + { + id: 'heavy-read-fanout', name: 'Heavy-Read Fan-Out', pillarId: 'paradigms', + topicId: 'heavy-read', + keywords: ['read-heavy', 'fan-out', 'fanout on write', 'fanout on read', 'timeline cache', 'celebrity problem'], + components: ['cache', 'cdn'], + summary: 'Serve millions of reads per write: cache tiers, CDNs, precomputed timelines.', + }, + { + id: 'spatial-grid', name: 'Real-Time Spatial Grid', pillarId: 'paradigms', + topicId: 'spatial-grid', + keywords: ['geohash', 'quadtree', 'geospatial index', 'proximity search', 'spatial grid'], + components: [], + summary: 'Geohash/quadtree partitioning plus live location streams (Uber-style).', + }, + { + id: 'multi-agent-blackboard', name: 'Multi-Agent Blackboard', pillarId: 'paradigms', + topicId: 'multi-agent', + keywords: ['multi-agent', 'blackboard pattern', 'agent orchestration', 'shared workspace'], + components: ['event-bus'], + summary: 'Agents coordinating through a shared event workspace with tool contracts.', + }, +] + +/** + * Directed prerequisite edges: learn `from` before `to`. + * Kept as an explicit list (not nested in nodes) so tests can validate + * the whole graph in one pass. + */ +export const GRAPH_EDGES = [ + // Fundamentals โ†’ building blocks + { from: 'client-server', to: 'http-rest' }, + { from: 'client-server', to: 'dns' }, + { from: 'client-server', to: 'scalability-basics' }, + { from: 'latency-throughput', to: 'capacity-estimation' }, + { from: 'latency-throughput', to: 'caching-fundamentals' }, + { from: 'scalability-basics', to: 'load-balancing' }, + { from: 'dns', to: 'load-balancing' }, + { from: 'http-rest', to: 'reverse-proxy' }, + { from: 'load-balancing', to: 'reverse-proxy' }, + { from: 'scalability-basics', to: 'stateless-services' }, + { from: 'http-rest', to: 'stateless-services' }, + { from: 'stateless-services', to: 'serverless' }, + { from: 'sql-basics', to: 'db-indexing' }, + { from: 'sql-basics', to: 'transactions-acid' }, + { from: 'sql-basics', to: 'nosql-types' }, + { from: 'scalability-basics', to: 'nosql-types' }, + { from: 'scalability-basics', to: 'object-storage' }, + { from: 'caching-fundamentals', to: 'cache-strategies' }, + { from: 'cache-strategies', to: 'cache-invalidation' }, + { from: 'caching-fundamentals', to: 'cdn' }, + { from: 'dns', to: 'cdn' }, + { from: 'stateless-services', to: 'message-queues' }, + { from: 'message-queues', to: 'pubsub-logs' }, + { from: 'http-rest', to: 'websockets-streaming' }, + { from: 'http-rest', to: 'grpc-serialization' }, + { from: 'http-rest', to: 'agentic-tools' }, + { from: 'reverse-proxy', to: 'rate-limiting' }, + { from: 'availability-slos', to: 'timeouts-retries' }, + { from: 'http-rest', to: 'timeouts-retries' }, + { from: 'timeouts-retries', to: 'circuit-breakers' }, + { from: 'rate-limiting', to: 'load-shedding' }, + { from: 'message-queues', to: 'load-shedding' }, + { from: 'timeouts-retries', to: 'idempotency' }, + { from: 'message-queues', to: 'idempotency' }, + { from: 'availability-slos', to: 'metrics-logs' }, + { from: 'metrics-logs', to: 'distributed-tracing' }, + { from: 'stateless-services', to: 'distributed-tracing' }, + { from: 'db-indexing', to: 'full-text-search' }, + { from: 'db-indexing', to: 'vector-search' }, + { from: 'db-indexing', to: 'lsm-trees' }, + { from: 'sql-basics', to: 'olap-warehousing' }, + + // Distributed mechanics + { from: 'availability-slos', to: 'replication' }, + { from: 'transactions-acid', to: 'replication' }, + { from: 'replication', to: 'replication-lag' }, + { from: 'hashing-fundamentals', to: 'partitioning' }, + { from: 'scalability-basics', to: 'partitioning' }, + { from: 'partitioning', to: 'consistent-hashing' }, + { from: 'consistent-hashing', to: 'virtual-nodes' }, + { from: 'replication', to: 'cap-theorem' }, + { from: 'partitioning', to: 'cap-theorem' }, + { from: 'cap-theorem', to: 'consistency-models' }, + { from: 'replication-lag', to: 'consistency-models' }, + { from: 'replication', to: 'quorums' }, + { from: 'cap-theorem', to: 'quorums' }, + { from: 'availability-slos', to: 'failure-detection' }, + { from: 'timeouts-retries', to: 'failure-detection' }, + { from: 'replication', to: 'leader-election' }, + { from: 'failure-detection', to: 'leader-election' }, + { from: 'leader-election', to: 'consensus' }, + { from: 'quorums', to: 'consensus' }, + { from: 'consensus', to: 'coordination-services' }, + { from: 'consistent-hashing', to: 'distributed-kv' }, + { from: 'quorums', to: 'distributed-kv' }, + { from: 'nosql-types', to: 'distributed-kv' }, + { from: 'failure-detection', to: 'distributed-kv' }, + { from: 'distributed-kv', to: 'virtual-nodes' }, + { from: 'transactions-acid', to: 'distributed-transactions' }, + { from: 'message-queues', to: 'distributed-transactions' }, + { from: 'consensus', to: 'distributed-transactions' }, + { from: 'pubsub-logs', to: 'stream-processing' }, + { from: 'object-storage', to: 'batch-processing' }, + { from: 'partitioning', to: 'batch-processing' }, + { from: 'batch-processing', to: 'olap-warehousing' }, + { from: 'message-queues', to: 'hitl-gateways' }, + { from: 'metrics-logs', to: 'hitl-gateways' }, + { from: 'metrics-logs', to: 'eval-frameworks' }, + + // Paradigms (capstones) + { from: 'capacity-estimation', to: 'heavy-write-pipeline' }, + { from: 'pubsub-logs', to: 'heavy-write-pipeline' }, + { from: 'lsm-trees', to: 'heavy-write-pipeline' }, + { from: 'load-shedding', to: 'heavy-write-pipeline' }, + { from: 'capacity-estimation', to: 'heavy-read-fanout' }, + { from: 'cache-invalidation', to: 'heavy-read-fanout' }, + { from: 'cdn', to: 'heavy-read-fanout' }, + { from: 'replication-lag', to: 'heavy-read-fanout' }, + { from: 'partitioning', to: 'spatial-grid' }, + { from: 'websockets-streaming', to: 'spatial-grid' }, + { from: 'agentic-tools', to: 'multi-agent-blackboard' }, + { from: 'pubsub-logs', to: 'multi-agent-blackboard' }, +] + +/** + * Curated learning tracks. `targets` are the capstone nodes; the full + * track is the target set plus every transitive prerequisite, in + * topological order (expandTrack). + */ +export const LEARNING_TRACKS = [ + { + id: 'senior-distributed', + name: 'Senior Distributed Systems', + emoji: '๐Ÿง ', + description: 'Consensus, quorums, and Dynamo-style stores โ€” the deep end interviewers use to separate senior candidates.', + targets: ['consensus', 'coordination-services', 'distributed-kv', 'virtual-nodes', 'distributed-transactions'], + }, + { + id: 'storage-consistency', + name: 'Storage & Consistency', + emoji: '๐Ÿ—„๏ธ', + description: 'From B-trees and LSM trees to replication lag and consistency models.', + targets: ['lsm-trees', 'consistency-models', 'quorums', 'olap-warehousing'], + }, + { + id: 'caching-performance', + name: 'Caching & Read Performance', + emoji: 'โšก', + description: 'The full read path: cache tiers, invalidation, CDNs, and fan-out architectures.', + targets: ['heavy-read-fanout'], + }, + { + id: 'resilience-traffic', + name: 'Resilience & Traffic Control', + emoji: '๐Ÿ›ก๏ธ', + description: 'Keeping systems alive under failure and overload: retries, breakers, shedding, idempotency.', + targets: ['circuit-breakers', 'load-shedding', 'idempotency'], + }, + { + id: 'realtime-streaming', + name: 'Real-Time & Streaming', + emoji: '๐ŸŒŠ', + description: 'Event logs, stream processing, realtime push, and spatial systems.', + targets: ['stream-processing', 'spatial-grid', 'heavy-write-pipeline'], + }, + { + id: 'ai-systems', + name: 'AI & Agentic Systems', + emoji: '๐Ÿค–', + description: 'Vector search, evals, HITL gateways, and multi-agent architectures.', + targets: ['multi-agent-blackboard', 'vector-search', 'eval-frameworks', 'hitl-gateways'], + }, +] + +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + Pure graph algorithms + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ + +/** Map: nodeId โ†’ node. */ +export function nodeMap(nodes = GRAPH_NODES) { + const map = new Map() + for (const n of nodes) map.set(n.id, n) + return map +} + +/** Map: nodeId โ†’ array of prerequisite ids (direct only). */ +export function prerequisitesOf(edges = GRAPH_EDGES) { + const map = new Map() + for (const e of edges) { + if (!map.has(e.to)) map.set(e.to, []) + map.get(e.to).push(e.from) + } + return map +} + +/** Map: nodeId โ†’ array of dependent ids (direct only). */ +export function dependentsOf(edges = GRAPH_EDGES) { + const map = new Map() + for (const e of edges) { + if (!map.has(e.from)) map.set(e.from, []) + map.get(e.from).push(e.to) + } + return map +} + +/** + * Validate the graph. Throws with a clear message when: + * - an edge references a missing node + * - the graph contains a cycle (prerequisites must form a DAG) + * - a node id is duplicated + */ +export function validateGraph(nodes = GRAPH_NODES, edges = GRAPH_EDGES) { + const ids = new Set() + for (const n of nodes) { + if (ids.has(n.id)) throw new Error(`Duplicate node id: ${n.id}`) + ids.add(n.id) + } + for (const e of edges) { + if (!ids.has(e.from)) throw new Error(`Edge references missing node: ${e.from}`) + if (!ids.has(e.to)) throw new Error(`Edge references missing node: ${e.to}`) + if (e.from === e.to) throw new Error(`Self-loop on node: ${e.from}`) + } + const order = topologicalSort(nodes, edges) + if (order.length !== nodes.length) { + const sorted = new Set(order) + const cyclic = nodes.filter((n) => !sorted.has(n.id)).map((n) => n.id) + throw new Error(`Cycle detected in prerequisite graph involving: ${cyclic.join(', ')}`) + } + return true +} + +/** + * Kahn's algorithm. Returns node ids in prerequisite order. + * When the graph has a cycle the result is shorter than `nodes` โ€” + * validateGraph() turns that into an explicit error. + * Ties break on the original node-array order, so the sort is stable. + */ +export function topologicalSort(nodes = GRAPH_NODES, edges = GRAPH_EDGES) { + const indegree = new Map(nodes.map((n) => [n.id, 0])) + const adj = new Map(nodes.map((n) => [n.id, []])) + for (const e of edges) { + if (!indegree.has(e.from) || !indegree.has(e.to)) continue + indegree.set(e.to, indegree.get(e.to) + 1) + adj.get(e.from).push(e.to) + } + const orderIndex = new Map(nodes.map((n, i) => [n.id, i])) + const queue = nodes.filter((n) => indegree.get(n.id) === 0).map((n) => n.id) + const result = [] + while (queue.length > 0) { + queue.sort((a, b) => orderIndex.get(a) - orderIndex.get(b)) + const id = queue.shift() + result.push(id) + for (const next of adj.get(id)) { + indegree.set(next, indegree.get(next) - 1) + if (indegree.get(next) === 0) queue.push(next) + } + } + return result +} + +/** + * Depth of each node = longest prerequisite chain above it. + * Foundations sit at depth 0. Used for the left-to-right layout. + */ +export function nodeDepths(nodes = GRAPH_NODES, edges = GRAPH_EDGES) { + const prereqs = prerequisitesOf(edges) + const depths = new Map() + for (const id of topologicalSort(nodes, edges)) { + const above = prereqs.get(id) || [] + depths.set(id, above.length === 0 ? 0 : Math.max(...above.map((p) => (depths.get(p) ?? 0))) + 1) + } + return depths +} + +/** All transitive prerequisite ids of `nodeId` (excludes the node itself). */ +export function ancestorsOf(nodeId, edges = GRAPH_EDGES) { + const prereqs = prerequisitesOf(edges) + const seen = new Set() + const stack = [...(prereqs.get(nodeId) || [])] + while (stack.length > 0) { + const id = stack.pop() + if (seen.has(id)) continue + seen.add(id) + for (const p of prereqs.get(id) || []) stack.push(p) + } + return seen +} + +/** All transitive dependent ids of `nodeId` (excludes the node itself). */ +export function descendantsOf(nodeId, edges = GRAPH_EDGES) { + const deps = dependentsOf(edges) + const seen = new Set() + const stack = [...(deps.get(nodeId) || [])] + while (stack.length > 0) { + const id = stack.pop() + if (seen.has(id)) continue + seen.add(id) + for (const d of deps.get(id) || []) stack.push(d) + } + return seen +} + +/** + * Expand a learning track into its full ordered node-id list: + * targets plus all transitive prerequisites, in topological order. + */ +export function expandTrack(track, nodes = GRAPH_NODES, edges = GRAPH_EDGES) { + const wanted = new Set(track.targets) + for (const target of track.targets) { + for (const anc of ancestorsOf(target, edges)) wanted.add(anc) + } + return topologicalSort(nodes, edges).filter((id) => wanted.has(id)) +} + +/** + * Match one flashcard to graph nodes. + * Keyword hits win; the source-topic link is the fallback when no + * keyword matches (it is coarser โ€” a topic can host several nodes). + * + * @param {{front: string, back: string, source_topic_id?: string}} card + * @param {Array} [nodes] + * @returns {string[]} Array of matching node ids (possibly empty) + */ +export function nodesForCard(card, nodes = GRAPH_NODES) { + const text = `${card.front || ''} ${card.back || ''}`.toLowerCase() + const keywordHits = [] + for (const node of nodes) { + if (node.keywords.some((kw) => text.includes(kw))) keywordHits.push(node.id) + } + if (keywordHits.length > 0) return keywordHits + if (card.source_topic_id) { + return nodes.filter((n) => n.topicId === card.source_topic_id).map((n) => n.id) + } + return [] +}