From 7d62a104fecd59fb5d4aecb97dc6ff6a9c761dab Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sat, 22 Aug 2026 17:13:06 +0530 Subject: [PATCH 1/4] Add _headers for CDN caching of root page The _routes.json excludes "/" from middleware to reduce TTFB, but without the middleware's cache headers, the CDN couldn't cache the homepage. This adds Cache-Control with s-maxage=86400 so the CDN can serve the page from edge cache. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- public/_headers | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 public/_headers diff --git a/public/_headers b/public/_headers new file mode 100644 index 00000000..65ff27bf --- /dev/null +++ b/public/_headers @@ -0,0 +1,2 @@ +/ + Cache-Control: public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800 From 4d41caaba86aa5ca99e47fc5f52193cadc17ad4f Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sat, 22 Aug 2026 17:46:09 +0530 Subject: [PATCH 2/4] Remove _routes.json to let middleware handle all routes The _routes.json was excluding "/" and static assets from middleware to reduce TTFB. However, the middleware adds important cache headers (Cache-Control with s-maxage) and Vary headers that the _headers file alone doesn't fully replicate. Removing _routes.json lets the middleware handle all routes consistently. The _headers file remains for CDN cache headers on the root page. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- public/_routes.json | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 public/_routes.json diff --git a/public/_routes.json b/public/_routes.json deleted file mode 100644 index c6b537ae..00000000 --- a/public/_routes.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "version": 1, - "include": ["/*"], - "exclude": [ - "/", - "/assets/*", - "/curriculum/*", - "/system-design/*", - "/favicon.svg", - "/favicon.ico", - "/favicon-32.png", - "/icon.svg", - "/og-image.svg", - "/apple-touch-icon.png", - "/vite.svg", - "/robots.txt", - "/sitemap.xml", - "/llms.txt", - "/llms-full.txt", - "/index.md", - "/api-ai.json", - "/changelog.html", - "/changelog.md", - "/.well-known/*" - ] -} From 3fab059afdb7670c5b1eeea226969705c9629cf7 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sat, 22 Aug 2026 23:53:00 +0530 Subject: [PATCH 3/4] fix(recommend): report untouched concepts as gaps, and give breadth a docs home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `weakConcepts()` filtered on `mastery[c.id] && …`, so a concept never opened could not be reported as a gap at any surface. The product's goal is breadth — coverage plus retention — which is exactly a claim about absence, so the app could say you were shaky on something you had studied but never that you had not opened distributed systems at all. Sweep's ROI ranking already worked around this with its own `isUnknown`; the recommender itself still did not. `conceptGaps()` replaces it and returns both kinds of thin: `shaky` (studied, confidence under the bar) and `uncovered` (no mastery row at all). Shaky is reported first because it is decaying now, while an uncovered concept has been at zero since the catalogue was written; within the uncovered group the order is `sweepOrder`, so the recommender and a triage pass agree on what comes next. Deliberately not filtered by prereq reachability — prereq gating reads mastery, so for a learner who has touched nothing every gap would be filtered back out, which is the same blind spot arriving by a second route. Both consumers propagate the distinction to the UI rather than only the data: /progress and /learn/all now label an uncovered concept `never opened` instead of "0% confident", which implies a measurement that was never taken, and the uncovered card offers Read rather than Review. The stale claim in roi.ts's header — that the recommender cannot see untouched concepts — is corrected. The regression tests pass an empty mastery map on purpose. The old test gave every concept in its fixture a mastery row, which is precisely why a passing suite never noticed; verified that the new assertion fails against the old implementation (expected 5, received 0). Docs: the surface was built before its proposal was written, inverting the spec-before-feature rule. The OpenSpec change closed that on the spec side but lives outside the canonical docs tree, so `docs/product/breadth-sweep.md` is now its home — rating semantics, the coverage fix, the surfaces that consume it, and the deferred cross-device sync that still needs owner approval before any schema change. Generator details link to content-pipelines.md rather than repeating them; the durable lesson (a truthiness guard deletes the meaning of absence, and an all-keys-present fixture will never catch it) is one entry in learnings.md. Cross-device sync remains unbuilt and unapproved. No schema was touched. Co-Authored-By: Claude Opus 5 (1M context) --- docs/index.md | 1 + docs/knowledge/learnings.md | 11 ++ docs/product/breadth-sweep.md | 179 ++++++++++++++++++ docs/product/overview.md | 1 + docs/product/surfaces.md | 2 +- .../2026-07-26-sweep-breadth-triage/tasks.md | 11 ++ src/lib/recommend.test.ts | 55 +++++- src/lib/recommend.ts | 65 ++++++- src/lib/roi.test.ts | 4 +- src/lib/roi.ts | 15 +- src/pages/LearnAll.tsx | 49 +++-- src/pages/Progress.tsx | 27 +-- 12 files changed, 370 insertions(+), 50 deletions(-) create mode 100644 docs/product/breadth-sweep.md diff --git a/docs/index.md b/docs/index.md index 919fccc7..d7c2671e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,6 +14,7 @@ in [`STATUS.md`](https://github.com/Significant-Hobbies/swe-interview-prep/blob/ | --- | --- | | [`product/overview.md`](product/overview.md) | What the product is, its surfaces, and competitive context | | [`product/systems-lab.md`](product/systems-lab.md) | Deterministic Systems Lab boundary, learning loop, and authoring contract | +| [`product/breadth-sweep.md`](product/breadth-sweep.md) | Breadth layer: triage at `/sweep`, ROI ranking, coverage gaps, and what is deferred | | [`architecture/overview.md`](architecture/overview.md) | System architecture, request/data flow, and decision records (ADRs) | | [`architecture/decisions/README.md`](architecture/decisions/README.md) | Architecture Decision Records — why a choice was made | | [`development/commands.md`](development/commands.md) | Local setup, commands, environment, testing, content pipelines | diff --git a/docs/knowledge/learnings.md b/docs/knowledge/learnings.md index 04821cf8..ddd526a1 100644 --- a/docs/knowledge/learnings.md +++ b/docs/knowledge/learnings.md @@ -4,6 +4,17 @@ Reusable lessons that are not obvious from the code. Add new entries at the top with a date. One lesson per bullet; link to the code or ADR that exemplifies it. +## 2026-08 — A truthiness filter can make absence unreportable + +`weakConcepts()` filtered on `mastery[c.id] && …`, so a concept never opened +could not be reported as a gap at any surface — and the product's goal is +coverage, which is exactly a statement about absence. The tell was a passing +test whose fixture gave every concept a mastery row, so the blind spot was +invisible to it. Generalize: when a record's *absence* carries meaning, a +truthiness guard silently deletes that meaning, and a fixture where every key +is present will never catch it. Fix and reasoning: +[`breadth-sweep.md`](../product/breadth-sweep.md). + ## 2026-08 — Learning actions are Fetch handlers, not Express Production already authenticates in the Pages Function and diff --git a/docs/product/breadth-sweep.md b/docs/product/breadth-sweep.md new file mode 100644 index 00000000..b7f882ef --- /dev/null +++ b/docs/product/breadth-sweep.md @@ -0,0 +1,179 @@ +# Breadth Sweep + +Scope: the breadth layer — the triage pass at `/sweep`, the ROI ranking it +feeds, and the coverage gaps the rest of the app reports. This page records +what was built and why, including the parts that were deliberately not built. +Route inventory lives in [`surfaces.md`](surfaces.md); the source-hub generator +is documented in +[`content-pipelines.md`](../development/content-pipelines.md#source-hubs); +code remains authoritative for behaviour. + +## Why a breadth layer exists + +The owner's goal for this product is breadth: enough working knowledge across +distributed systems, infrastructure, databases, and AI to recognise a problem +mid-build, know the trade-off, and know what to reach for. That goal is served +by **coverage and retention**, not by implementation practice — which is the +one thing the rest of the app was already good at (see +[`overview.md`](overview.md), *Core principle*). + +Two things blocked it. + +**The catalogue had no fast traversal.** Every concept carries a `mentalModel` +of a few dozen words, so reading the whole interest surface is roughly a +weekend — a realistic goal rather than an aspiration. But no surface was built +for covering ground; every one of them was built for going deep on one thing. + +**The app could not say where effort was worth most.** The recommender filtered +on `mastery[c.id] && …`, so a concept never touched could not be reported as a +gap. It could say you were shaky on something you had studied, never that you +had not opened distributed systems at all. + +## What was built + +| Piece | Role | +| --- | --- | +| `/sweep` route (`src/pages/Sweep.tsx`) | Domain picker plus a keyboard rating runner | +| `src/lib/sweep.ts` | Pure triage logic: queue order, coverage, rating → writes | +| `src/lib/roi.ts` | Domain ranking and hub matching over sweep state | +| `scripts/build-source-hubs.mjs` | Derives `src/data/source-hubs.json` from the concept packs | +| `mutedTags` on `LearnerProfile` | Declared interest, stored as a negative | +| `src/lib/recommend.ts` → `conceptGaps()` | The coverage-gap fix, consumed by Progress and Browse | + +`/sweep` is reachable from `/learn` and is deliberately absent from +`SITE_NAV_ITEMS`, because that list is server-rendered into the generated +curriculum pages and adding an entry costs a full regeneration of them. + +## Rating semantics + +A sweep rating is self-assessment, not recall performance, so each one maps +onto the FSRS grade that produces the interval the claim deserves. FSRS itself +is unchanged — Sweep is a new *producer* of ratings, not a new scheduler (see +[ADR 0004](../architecture/decisions/0004-fsrs-spaced-repetition.md)). + +| Sweep rating | FSRS grade | Seeds review cards | +| --- | --- | --- | +| Known | `easy` | **No** | +| Fuzzy | `hard` | Yes | +| New to me | `again` | Yes | + +Known seeding nothing is the load-bearing decision. Concept mastery still +moves, so the claim is recorded and the ranking can use it, but seeding cards +for every concept already known produces a queue holding the entire catalogue +— the exact failure this feature exists to prevent. The accepted cost is that a +wrong self-assessment is never re-tested by the queue; it surfaces through a +drill or a roadmap instead, and the concept can be re-swept. Whether Known +should be spot-checked at a low sampling rate is an open question, tracked in +[GitHub Issues](https://github.com/Significant-Hobbies/swe-interview-prep/issues). + +Interest is stored as a **negative**. With genuinely broad interests, asking +the learner to rank thirty domains yields "all of them" and no signal; muting +the few that do not matter states the same thing truthfully in one interaction. + +## Coverage gaps: both kinds of thin + +`conceptGaps()` in `src/lib/recommend.ts` replaced `weakConcepts()`, which +could only report concepts that already had a mastery row. It now returns two +kinds of gap: + +- **shaky** — studied, but FSRS confidence is under the bar. Decaying now. +- **uncovered** — no mastery row at all. Never opened. + +Shaky is reported first because it is time-sensitive; uncovered has been at +zero for as long as the catalogue has existed and keeps until tomorrow. Within +the uncovered group the order is the same one a triage pass would use — +foundations before frontier, then editorial priority — so the two surfaces +agree about what to do next. + +Gaps are deliberately **not** filtered by prerequisite reachability. Prereq +gating reads mastery, so for a learner who has touched nothing every +prerequisite is unmet and every gap would be filtered back out — the same blind +spot arriving by a second route. `pickNextConcept()` is the function that owes +the learner a reachable next step; `conceptGaps()` owes them the truth about +coverage. + +Surfaces that consume it: + +| Surface | What it shows | +| --- | --- | +| `/progress` | "Biggest gaps" — three thinnest concepts, labelled `never opened` when uncovered | +| `/learn/all` | "Biggest gaps" panel — four cards; uncovered cards offer *Read* rather than *Review* | +| `/sweep` | Domain ranking, which counts an untouched concept as unknown by construction | + +The distinction has to reach the UI, not just the data: a concept never opened +renders as `never opened` rather than as "0% confident", because the second +implies a measurement that was never taken. + +## Ranking and outside sources + +`rankDomains()` orders domains by the gaps this app can actually close — +unknown concepts minus the ones whose mental model is too thin to learn from — +excluding muted domains, and flags a domain whose figure is an untriaged upper +bound rather than a measurement. + +For each domain it names at most one outside source: the single coherent body +of work covering the most of that domain's remaining gaps, or nothing at all. +Being able to say "no hub" is the point — it exposes content debt instead of +hiding it behind a bad suggestion. The floors that make that possible, and the +publisher exclusions and path-scoped hosts that make the hub index usable, are +documented with the generator in +[`content-pipelines.md`](../development/content-pipelines.md#source-hubs). + +No model and no LLM appears anywhere in the ranking. It is set intersection +over already-verified catalogue data and the learner's own sweep state, so +nothing inferred is ever written back into the catalogue. + +## Storage and privacy + +Sweep state lives in `localStorage` under `swe-os:sweep-v1:`, one key +per account. Signing in **adopts** a guest pass rather than discarding it, and +clears the guest key once adopted — so an hour of anonymous triage is not lost +at the moment of login, and the next account to sign in on the same browser +does not inherit the previous one's ratings. + +A rating is committed only once the writes it implies have landed. If a mastery +write is rejected, the concept stays in the queue, nothing is recorded, and the +failure is shown; the mastery hooks were extended additively to report write +failure for exactly this. + +## Not built: cross-device sync + +Sweep state is the only store here with no server round-trip, so a pass done on +a laptop shows 0% elsewhere, and re-sweeping re-grades already-scheduled cards. +This is a known limitation, not an oversight. + +Closing it requires a new table, a hand-mirrored copy of the schema in +`functions/api/[[path]].js`, a handler, a registry entry, and a hook. That is a +schema change plus a new API capability on a product whose status is +maintenance-only, so it is **specified and awaiting owner approval rather than +built**. The cheaper alternative — folding ratings into `profile_json` — was +considered and rejected: profile writes are whole-object PUTs merged against a +possibly-stale local copy, so a 250-rating pass could clobber unrelated +settings. The full comparison is in the +[change design](https://github.com/Significant-Hobbies/swe-interview-prep/blob/main/openspec/changes/2026-07-26-sweep-breadth-triage/design.md). + +## Process note + +This surface was built *before* its proposal was written, which inverts the +rule that a spec precedes a non-trivial feature. The +[spec-driven change](https://github.com/Significant-Hobbies/swe-interview-prep/tree/main/openspec/changes/2026-07-26-sweep-breadth-triage) +and its +[tracking issue](https://github.com/Significant-Hobbies/swe-interview-prep/issues/79) +were written afterwards to close that gap; this page is its home in the +canonical docs tree, where a reader looking for the product's breadth model +will actually find it. + +The useful half of writing a spec after the fact is what it forced into the +open. Two decisions were still unmade — cross-device sync and whether Known +should be spot-checked — and the first is a schema change. Writing them down +put them under review *before* they were built, which is the value the rule was +protecting in the first place. The remaining open items are tracked in +[GitHub Issues](https://github.com/Significant-Hobbies/swe-interview-prep/issues), +not here. + +## Related + +- [`surfaces.md`](surfaces.md) — route and API inventory +- [`content-pipelines.md`](../development/content-pipelines.md#source-hubs) — how the hub index is generated +- [ADR 0004](../architecture/decisions/0004-fsrs-spaced-repetition.md) — why FSRS owns scheduling +- [`learnings.md`](../knowledge/learnings.md) — the reusable lesson behind the coverage fix diff --git a/docs/product/overview.md b/docs/product/overview.md index 623cb211..1ee5a756 100644 --- a/docs/product/overview.md +++ b/docs/product/overview.md @@ -148,6 +148,7 @@ roadmap expansion beyond maintenance and personally requested workflow fixes. - [`surfaces.md`](surfaces.md) — routes and API surface inventory - [`learning-library.md`](learning-library.md) — the embedded GitHub library feature - [`systems-lab.md`](systems-lab.md) — safe simulation boundary and learning contract +- [`breadth-sweep.md`](breadth-sweep.md) — breadth triage, ROI ranking, and coverage-gap reporting - [Harness Engineering](https://learn.significanthobbies.com/learning/harness-engineering) — the seven-build agent-harness path - [`../knowledge/curriculum-coverage-sources.md`](../knowledge/curriculum-coverage-sources.md) — external coverage audits and native-content boundary - [`../architecture/overview.md`](../architecture/overview.md) — how it's built diff --git a/docs/product/surfaces.md b/docs/product/surfaces.md index ef264f8e..60715ffe 100644 --- a/docs/product/surfaces.md +++ b/docs/product/surfaces.md @@ -14,7 +14,7 @@ disagrees with code, code wins. | `/today` | Legacy redirect to `/dashboard` | | `/learn`, `/learn/all` | Searchable high-level learning entry plus the complete concept catalogue | | `/explore` | Concept/roadmap explorer | -| `/sweep`, `/sweep?domain=` | Breadth triage — rate every concept Known/Fuzzy/New; ROI ranking + domain muting. Reachable from `/learn`, deliberately not in `SITE_NAV_ITEMS`; open follow-up lives in [GitHub Issues](https://github.com/Significant-Hobbies/swe-interview-prep/issues) | +| `/sweep`, `/sweep?domain=` | Breadth triage — rate every concept Known/Fuzzy/New; ROI ranking + domain muting. Reachable from `/learn`, deliberately not in `SITE_NAV_ITEMS`. Model and deferred work: [`breadth-sweep.md`](breadth-sweep.md); open follow-up lives in [GitHub Issues](https://github.com/Significant-Hobbies/swe-interview-prep/issues) | | `/practice` | Playground workspace with a selector over the complete canonical problem inventory | | `/practice/all` | Complete drill catalogue and spaced-repetition reviews | | `/playground` | Stable alias for the same Playground workspace | diff --git a/openspec/changes/2026-07-26-sweep-breadth-triage/tasks.md b/openspec/changes/2026-07-26-sweep-breadth-triage/tasks.md index 851716df..a3ddd448 100644 --- a/openspec/changes/2026-07-26-sweep-breadth-triage/tasks.md +++ b/openspec/changes/2026-07-26-sweep-breadth-triage/tasks.md @@ -34,6 +34,17 @@ - [x] Missing live region, low-contrast shortcut legend, undo shortcut unlabelled - [x] Five dead exports removed +## Fixed after review (issue #79) + +- [x] `weakConcepts()` replaced by `conceptGaps()` — untouched concepts are now + reportable as gaps, so the blind spot named in the proposal is closed at + the recommender rather than only at the Sweep ranking. Propagated to + `/progress` and `/learn/all`, which label an uncovered concept + `never opened` rather than "0% confident". Regression tests in + `src/lib/recommend.test.ts` pass an empty mastery map on purpose — the + old fixture gave every concept a row, which is why the bug survived. +- [x] Canonical docs home: `docs/product/breadth-sweep.md` + ## Open - [ ] Cross-device sync — needs approval before touching the schema: diff --git a/src/lib/recommend.test.ts b/src/lib/recommend.test.ts index 5e7927d4..7d1aa7d5 100644 --- a/src/lib/recommend.test.ts +++ b/src/lib/recommend.test.ts @@ -4,14 +4,16 @@ import { CONCEPT_BY_ID } from '../hooks/useConcepts'; import type { MasteryEntry } from '../hooks/useConcepts'; import type { GateContext } from './gates'; import { DEFAULT_USER_ELO } from './elo'; +import { ALL_CONCEPTS } from '../hooks/useConcepts'; import { + conceptGaps, dueConcepts, dueReviewQuestions, pickDrillForConcept, pickNextConcept, prereqsMet, - weakConcepts, } from './recommend'; +import { sweepOrder } from './sweep'; function mastery(confidence: number, due?: string, reps = 3): MasteryEntry { return { @@ -95,12 +97,13 @@ describe('dashboard helpers', () => { expect(dueConcepts(m).map((c) => c.id)).toContain('tokenization'); }); - it('weakConcepts returns low-confidence started concepts', () => { + it('conceptGaps reports low-confidence started concepts as shaky', () => { const m = { bm25: mastery(0.2, undefined, 1), tokenization: mastery(0.9, undefined, 3), }; - expect(weakConcepts(m, 3).map((c) => c.id)).toEqual(['bm25']); + const shaky = conceptGaps(m, 40).filter((g) => g.kind === 'shaky'); + expect(shaky.map((g) => g.concept.id)).toEqual(['bm25']); }); it('pickDrillForConcept returns first mapped drill', () => { @@ -114,3 +117,49 @@ describe('dashboard helpers', () => { expect(dueReviewQuestions(m).length).toBeGreaterThan(0); }); }); + +/** + * The bug this suite exists for: `weakConcepts()` filtered on + * `mastery[c.id] && …`, so a concept never opened could not be reported as a + * gap at any surface. The old test passed a fixture where every concept had a + * mastery row, so the blind spot was invisible to it — hence the first case + * below, which passes an empty mastery map on purpose. + */ +describe('conceptGaps — untouched concepts count as gaps', () => { + it('reports untouched concepts when nothing has ever been studied', () => { + const gaps = conceptGaps({}, 5); + expect(gaps).toHaveLength(5); + expect(gaps.every((g) => g.kind === 'uncovered')).toBe(true); + expect(gaps.every((g) => g.confidence === 0)).toBe(true); + }); + + it('reports an untouched concept alongside a studied-but-shaky one', () => { + const m = { bm25: mastery(0.2, undefined, 1) }; + const gaps = conceptGaps(m, 3); + expect(gaps).toHaveLength(3); + // Shaky first — its confidence is decaying now; an uncovered concept has + // been at zero since the catalogue was written and keeps until tomorrow. + expect(gaps[0]).toMatchObject({ kind: 'shaky' }); + expect(gaps[0].concept.id).toBe('bm25'); + expect(gaps.slice(1).every((g) => g.kind === 'uncovered')).toBe(true); + expect(gaps.some((g) => g.concept.id === 'bm25' && g.kind === 'uncovered')).toBe(false); + }); + + it('reports nothing once every concept is touched and confident', () => { + const all: Record = {}; + for (const id of Object.keys(CONCEPT_BY_ID)) all[id] = mastery(0.95); + expect(conceptGaps(all, 6)).toEqual([]); + }); + + it('orders untouched concepts by sweepOrder — foundations before frontier', () => { + const gaps = conceptGaps({}, 250).map((g) => g.concept); + const expected = [...ALL_CONCEPTS].sort(sweepOrder); + expect(gaps.map((c) => c.id)).toEqual(expected.slice(0, gaps.length).map((c) => c.id)); + expect(gaps[0].difficulty).toBe('intro'); + }); + + it('honours the limit and covers the whole catalogue when asked', () => { + expect(conceptGaps({}, 1)).toHaveLength(1); + expect(conceptGaps({}, ALL_CONCEPTS.length)).toHaveLength(ALL_CONCEPTS.length); + }); +}); diff --git a/src/lib/recommend.ts b/src/lib/recommend.ts index 6ef0b11a..083f05f4 100644 --- a/src/lib/recommend.ts +++ b/src/lib/recommend.ts @@ -13,6 +13,7 @@ import { ALL_CONCEPTS, type Concept, type MasteryEntry } from '../hooks/useConce import { isSchedulableReviewQuestion } from './contentQuality'; import { deriveConceptStatus, isDue } from './conceptState'; import { type GateContext, conceptAccessible } from './gates'; +import { sweepOrder } from './sweep'; const PREREQ_THRESHOLD = 0.4; const ACTIVE_ROADMAP_KEY = 'swe-os:active-roadmap'; @@ -122,9 +123,63 @@ export function dueReviewQuestions(mastery: Record): Revie ); } -/** The weakest touched concepts — low confidence but already started. */ -export function weakConcepts(mastery: Record, limit = 6): Concept[] { - return ALL_CONCEPTS.filter((c) => mastery[c.id] && (mastery[c.id].confidence ?? 1) < 0.6) - .sort((a, b) => (mastery[a.id]?.confidence ?? 0) - (mastery[b.id]?.confidence ?? 0)) - .slice(0, limit); +/** Below this an FSRS confidence is not yet knowledge you can rely on. */ +const WEAK_CONFIDENCE = 0.6; + +/** + * `shaky` — studied, but confidence is under the bar. `uncovered` — no mastery + * row at all, i.e. never opened. The distinction is the whole point: only one + * of them decays, and only one of them is a coverage gap. + */ +type ConceptGapKind = 'shaky' | 'uncovered'; + +export interface ConceptGap { + concept: Concept; + kind: ConceptGapKind; + /** 0-1 FSRS confidence. Always 0 for `uncovered` — nothing was ever measured. */ + confidence: number; +} + +/** + * Where knowledge is thin — both ways it can be thin. + * + * This replaces `weakConcepts()`, which filtered on `mastery[c.id] && …` and so + * could only ever report concepts you had already touched. A concept never + * opened was indistinguishable from one that did not exist, which made the + * product unable to state its own goal: it could say you were shaky on + * something you had studied, never that you had not opened distributed systems + * at all. Breadth is coverage plus retention, so absence has to be reportable. + * + * Shaky comes before uncovered, and the order is deliberate. A shaky concept is + * time-sensitive — FSRS confidence is decaying while you read this — whereas an + * uncovered one has been at zero for as long as the catalog has existed and + * will keep until tomorrow. Within each group: shaky by confidence ascending, + * uncovered by `sweepOrder` (foundations before frontier, then editorial + * priority), which is the same order a triage pass would hand them to you. + * + * Deliberately NOT filtered by `reachable()`. Prereq gating reads mastery, so + * for a learner who has touched nothing every prerequisite is unmet and every + * gap would be filtered out — the same blind spot arriving by a second route. + * `pickNextConcept` is the surface that owes you a reachable next step; this + * one owes you the truth about coverage. + */ +export function conceptGaps(mastery: Record, limit = 6): ConceptGap[] { + const shaky: ConceptGap[] = []; + const uncovered: Concept[] = []; + for (const c of ALL_CONCEPTS) { + const entry = mastery[c.id]; + if (!entry) { + uncovered.push(c); + continue; + } + // An entry with no confidence field is a scheduled row, not a weak one. + const confidence = entry.confidence ?? 1; + if (confidence < WEAK_CONFIDENCE) shaky.push({ concept: c, kind: 'shaky', confidence }); + } + shaky.sort((a, b) => a.confidence - b.confidence); + uncovered.sort(sweepOrder); + return [ + ...shaky, + ...uncovered.map((concept): ConceptGap => ({ concept, kind: 'uncovered', confidence: 0 })), + ].slice(0, limit); } diff --git a/src/lib/roi.test.ts b/src/lib/roi.test.ts index ac9b0e25..9aeed396 100644 --- a/src/lib/roi.test.ts +++ b/src/lib/roi.test.ts @@ -45,8 +45,8 @@ const INCIDENTAL: SourceHub[] = [ describe('isUnknown', () => { it('treats untouched concepts as unknown — the blind spot this exists to fix', () => { - // recommend.ts `weakConcepts` requires mastery[c.id] to exist, so a domain - // you have never opened is invisible to it. Here, absence IS the signal. + // Absence IS the signal here: a concept is a gap until it is rated Known, + // so a domain never opened ranks rather than disappearing. expect(isUnknown('never-seen', {})).toBe(true); expect(isUnknown('x', { x: 'new' })).toBe(true); expect(isUnknown('x', { x: 'fuzzy' })).toBe(true); diff --git a/src/lib/roi.ts b/src/lib/roi.ts index b54e9c88..ac871e98 100644 --- a/src/lib/roi.ts +++ b/src/lib/roi.ts @@ -1,15 +1,14 @@ /** * Where the next hour is worth the most. * - * The app could not answer this before, for one structural reason: - * `weakConcepts()` in recommend.ts filters on `mastery[c.id] && …`, so a - * concept you have NEVER touched can never be reported as a gap. Every - * downstream surface inherited that blind spot — the app could say you were - * shaky on something you had studied, but never that you had not opened - * distributed systems at all. + * The app could not answer this before, for one structural reason: the + * recommender filtered on `mastery[c.id] && …`, so a concept never touched + * could not be reported as a gap. `conceptGaps()` in recommend.ts now counts + * untouched concepts too, and carries the full account of that bug. * - * Sweep fixes the input: after a triage pass, "not known" is observable rather - * than absent. This module turns that into a ranking. + * Sweep is the sharper input: after a triage pass, "not known" is a claim the + * learner made rather than an absence the app has to interpret. This module + * turns that into a ranking. * * Deliberately no model and no LLM — it is set intersection over * `concept-packs.json` and your own sweep state, so nothing inferred is ever diff --git a/src/pages/LearnAll.tsx b/src/pages/LearnAll.tsx index b130da51..0decaec5 100644 --- a/src/pages/LearnAll.tsx +++ b/src/pages/LearnAll.tsx @@ -38,7 +38,7 @@ import { ALL_CONCEPTS, type MasteryEntry, useConceptMastery } from '../hooks/use import { useGateContext } from '../hooks/useGates'; import { confidencePct, deriveConceptStatus, rollupMastery } from '../lib/conceptState'; import { conceptAccessible } from '../lib/gates'; -import { pickDrillForConcept, pickNextConcept, weakConcepts } from '../lib/recommend'; +import { conceptGaps, pickDrillForConcept, pickNextConcept } from '../lib/recommend'; type StatusFilter = 'all' | 'untouched' | 'active' | 'mastered'; @@ -92,7 +92,7 @@ export default function Learn() { mastery={mastery} /> - +
@@ -245,23 +245,28 @@ function ActivePathHero({ ); } -// --- Weak areas panel ------------------------------------------------------- +// --- Gaps panel ------------------------------------------------------------- -function WeakAreasPanel({ +/** + * Both kinds of gap, in one list: low confidence on something started, and + * concepts never opened at all. The second used to be unreportable — see + * `conceptGaps` in lib/recommend.ts. + */ +function GapsPanel({ mastery, loading, }: { mastery: Record; loading: boolean; }) { - const weak = weakConcepts(mastery, 4); + const gaps = conceptGaps(mastery, 4); const hasAnyMastery = Object.keys(mastery).length > 0; return (
{loading && !hasAnyMastery ? (
@@ -272,18 +277,16 @@ function WeakAreasPanel({ /> ))}
- ) : weak.length === 0 ? ( + ) : gaps.length === 0 ? ( ) : (
- {weak.map((c) => { + {gaps.map((gap) => { + const c = gap.concept; + const uncovered = gap.kind === 'uncovered'; const m = mastery[c.id]; const drill = pickDrillForConcept(c.id); const trk = primaryGroup(c); @@ -303,25 +306,31 @@ function WeakAreasPanel({ {trk &&
{trk.short}
}
- {pct}% + + {uncovered ? 'new' : `${pct}%`} +
- {m?.reps ?? 0} rep{(m?.reps ?? 0) !== 1 ? 's' : ''} + {uncovered + ? 'never opened' + : `${m?.reps ?? 0} rep${(m?.reps ?? 0) !== 1 ? 's' : ''}`}
- Review + {uncovered ? 'Read' : 'Review'} {drill && ( a.status === 'shipped').length; const sparkline = useMemo(() => buildRecentActivity(mastery, 30), [mastery]); const pct = overall.total ? (overall.mastered / overall.total) * 100 : 0; - // The 2-3 weakest touched concepts — the "what to master next" queue fed by - // drills, explain-backs (Feynman Gate), and reviews. - const weakest = useMemo(() => weakConcepts(mastery, 3), [mastery]); + // The 2-3 thinnest concepts — the "what to master next" queue fed by drills, + // explain-backs (Feynman Gate), and reviews. Includes concepts never opened, + // which is the only way this page can report a coverage gap rather than only + // a confidence one. + const gaps = useMemo(() => conceptGaps(mastery, 3), [mastery]); return (
@@ -61,23 +63,26 @@ export default function Progress() {
- {weakest.length > 0 && ( + {gaps.length > 0 && (
- Weakest concepts + Biggest gaps
- {weakest.map((c) => ( + {gaps.map((gap) => ( - Next: master {c.name} → + {gap.kind === 'uncovered' ? 'Next: cover' : 'Next: master'} {gap.concept.name}{' '} + → - {confidencePct(mastery[c.id])}% confident + {gap.kind === 'uncovered' + ? 'never opened' + : `${confidencePct(mastery[gap.concept.id])}% confident`} ))} From 09c3c9d6837c7eb2ef91ea9a843d67f71c98b331 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sun, 23 Aug 2026 02:36:23 +0530 Subject: [PATCH 4/4] Link the FSRS ADR the way every other doc does CI's `blume validate` rejected two relative links to ../architecture/decisions/0004-fsrs-spaced-repetition.md in the new breadth-sweep doc. The file exists; Blume just does not resolve relative paths into the decisions/ subtree, and every other doc in the repo already links ADRs by absolute GitHub blob URL. Matched that convention. `blume validate` now reports 0 errors. The remaining warning about public/.github/workflows/deploy.yml is pre-existing in docs/operations/deploy.md and untouched. Worth noting for next time: `pnpm docs:validate` and `pnpm exec blume validate` are different checks. The first passed while the second was failing, so local verification missed what CI enforces. Co-Authored-By: Claude Opus 5 (1M context) --- docs/product/breadth-sweep.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/product/breadth-sweep.md b/docs/product/breadth-sweep.md index b7f882ef..cb2aaabd 100644 --- a/docs/product/breadth-sweep.md +++ b/docs/product/breadth-sweep.md @@ -49,7 +49,7 @@ curriculum pages and adding an entry costs a full regeneration of them. A sweep rating is self-assessment, not recall performance, so each one maps onto the FSRS grade that produces the interval the claim deserves. FSRS itself is unchanged — Sweep is a new *producer* of ratings, not a new scheduler (see -[ADR 0004](../architecture/decisions/0004-fsrs-spaced-repetition.md)). +[ADR 0004](https://github.com/Significant-Hobbies/swe-interview-prep/blob/main/docs/architecture/decisions/0004-fsrs-spaced-repetition.md)). | Sweep rating | FSRS grade | Seeds review cards | | --- | --- | --- | @@ -175,5 +175,5 @@ not here. - [`surfaces.md`](surfaces.md) — route and API inventory - [`content-pipelines.md`](../development/content-pipelines.md#source-hubs) — how the hub index is generated -- [ADR 0004](../architecture/decisions/0004-fsrs-spaced-repetition.md) — why FSRS owns scheduling +- [ADR 0004](https://github.com/Significant-Hobbies/swe-interview-prep/blob/main/docs/architecture/decisions/0004-fsrs-spaced-repetition.md) — why FSRS owns scheduling - [`learnings.md`](../knowledge/learnings.md) — the reusable lesson behind the coverage fix