From fa45a96a818c18d0521ee2f0d0fff975944c722b Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Mon, 26 Oct 2026 09:00:17 +0100 Subject: [PATCH] docs: bounded encyclopedia planning implementation plan Co-Authored-By: Claude Opus 5.5 --- ...026-10-26-encyclopedia-bounded-planning.md | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-26-encyclopedia-bounded-planning.md diff --git a/docs/superpowers/plans/2026-10-26-encyclopedia-bounded-planning.md b/docs/superpowers/plans/2026-10-26-encyclopedia-bounded-planning.md new file mode 100644 index 0000000..01bc210 --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-encyclopedia-bounded-planning.md @@ -0,0 +1,205 @@ +# Encyclopedia Bounded Planning Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the one-call article planner with a stored plan that every run updates through bounded model calls (assign, split, fold), and bound the writer's link list. + +**Architecture:** `_plan.json` becomes state (articles with scope, note→article assignments, redirects). A run diffs notes against assignments, assigns new notes in batches of similar notes against nearby articles found through the existing per-note vector index, splits oversized and folds thin articles, then writes stale articles as today. The host follows redirects with 301s after the visibility check. + +**Tech Stack:** .NET 10, xunit, hand-written fakes (`test/Wiki.Application.Tests/Fakes.cs`), Markdig, Blazor static SSR. + +**Spec:** `docs/superpowers/specs/2026-10-26-encyclopedia-bounded-planning-design.md` (planning), `docs/superpowers/specs/2026-10-24-house-encyclopedia-design.md` (everything else). + +## Global Constraints + +- No model call's input or output may grow with the number of notes or articles. Every list passed to a prompt is capped by a setting. +- No new NuGet packages, no mocking libraries; tests use the fakes in `Fakes.cs` (`FakeTextModel`, `FakeIndex`, `FakeEmbeddings`, `FakeStore`, `FakeCatalog`). +- `Prompts.Version` goes from 5 to 6. +- Defaults (in `EncyclopediaOptions` and `EncyclopediaSettings`): `AssignBatchSize` 20, `CandidateArticles` 10, `NeighbourNotes` 30, `NeighbourSources` 10, `MaxAssignBatchesPerRun` 120, `MaxSplitsPerRun` 5, `MaxFoldsPerRun` 5, `MaxSourcesPerArticle` 120, `FoldMinSources` 3, `FoldMinChars` 4000, `AssignMaxTokens` 8000, `SplitMaxTokens` 8000, `FoldMaxTokens` 1000. Unchanged: `ArticleMaxTokens` 16000, `MaxArticlesPerRun` 10, `MaxSourceCharsPerArticle` 300000, `PilotSlugs`, `MaxArticles`. `PlanMaxTokens` is removed. +- Slugs match `^[a-z0-9]+(-[a-z0-9]+)*$`. A slug in `redirects` is never reused. +- Comments state facts, terse; match surrounding code style (primary constructors, records, `sealed`). +- One commit per run, as today. Model-unavailable stops the run with what is staged committed. +- Build and test: `env --chdir= dotnet test Wiki.slnx` — all green before each commit. + +## Review Focus + +1. **Bootstrap from an empty plan:** no candidates exist, the first batches only create articles; later batches must see those as candidates *within the same run*. Pinned in Task 8 (`Run_Bootstrap_LaterBatchesSeeEarlierArticles`). +2. **A reply naming notes outside its batch or articles outside its candidates:** those entries are dropped, the batch's other entries kept. Pinned in Task 4. +3. **Removing the last source of an article that is a fold target or split parent:** article deleted, redirects pointing at it are removed so no chain dangles. Pinned in Task 1 (`RemoveNote_LastSource_DeletesArticleAndRedirectsToIt`). +4. **Redirect cycles and long chains:** resolution stops after 5 hops and treats the slug as not found. Pinned in Task 1 and Task 9. +5. **Embedding service down mid-run:** neighbour lookups throw `HttpRequestException`; the run stops assigning, keeps what is staged, still writes stale articles, answers 200. Pinned in Task 8. + +--- + +### Task 1: Plan model v2 + +**Files:** +- Modify: `src/Wiki.Application/Encyclopedia/ArticlePlan.cs` +- Test: `test/Wiki.Application.Tests/ArticlePlanTests.cs` (create) + +**Interfaces:** +- Produces: + - `record PlannedArticle(string Slug, string TitleDe, string TitleEn, ArticleCategory Category, string Scope, IReadOnlyList Sources)` (`Related` removed). + - `sealed class ArticlePlan` — mutable, the only writer of plan state: + - `IReadOnlyList Articles`, `IReadOnlyDictionary> Assignments` (note → article slugs; empty list = skipped), `IReadOnlyDictionary Redirects`. + - `static ArticlePlan Empty()`. + - `bool IsAssigned(string note)`; `PlannedArticle? Find(string slug)`; `bool SlugTaken(string slug)` (article or redirect). + - `void AddArticle(PlannedArticle a)` (throws `InvalidOperationException` when `SlugTaken`). + - `void Assign(string note, IReadOnlyList articles)` — appends the note to each article's `Sources` (no duplicates), records the assignment; `[]` records a skip. + - `IReadOnlyList RemoveNote(string note)` — drops it from every article and from assignments; returns the slugs of articles deleted because they lost their last source. Redirects pointing at a deleted article are deleted too. + - `void ReplaceArticle(PlannedArticle a)` (same slug, new scope/sources; keeps assignments consistent with the new `Sources`). + - `void Fold(string thin, string into)` — moves `thin`'s sources to the end of `into`'s, rewrites assignments, deletes `thin`, sets `Redirects[thin] = into`, and repoints existing redirects whose target was `thin`. + - `string? Resolve(string slug)` — follows `Redirects` up to 5 hops; returns the final article slug, `null` for unknown, cycles or > 5 hops. + - `static class ArticlePlanJson`: `string Write(ArticlePlan)` (indented, camelCase, shape from the spec), `ArticlePlan? Read(string json)` — returns `null` for malformed JSON and for the old format (no `assignments` key); drops articles with bad slug/category; drops assignments to unknown articles. + +- [ ] **Step 1: Write the failing tests** — `ArticlePlanTests`: `AddArticle_TakenSlug_Throws` (incl. a slug that is a redirect), `Assign_AppendsSourcesInOrderWithoutDuplicates`, `Assign_Empty_RecordsSkip` (`IsAssigned` true, no article gains it), `RemoveNote_LastSource_DeletesArticleAndRedirectsToIt` (redirect `a→b`, remove b's only note ⇒ returns `["b"]`, `Redirects` no longer has `a`), `Fold_MovesSourcesAndRedirects` (also repoints `x→thin` to `x→into`), `Resolve_FollowsChain_StopsAtCycleAndFiveHops`, `Json_RoundTrips`, `Json_OldFormat_IsNull` (input `{"articles":[...],"coveredNotes":[...]}`), `Json_AssignmentToUnknownArticle_Dropped`. +- [ ] **Step 2: Run, verify they fail** — `dotnet test Wiki.slnx --filter ArticlePlanTests` → compile errors / failures. +- [ ] **Step 3: Implement** in `ArticlePlan.cs`. Existing callers (`PlanArticlesUseCase`, reconciler, writer, their tests) use `Related`/`CoveredNotes`/the old constructor: adapt them minimally so the solution builds and their tests pass (e.g. the old planner builds an `ArticlePlan` through `AddArticle`/`Assign`); Tasks 7–8 replace them. +- [ ] **Step 4: Run tests** — the new tests pass; the whole solution builds. +- [ ] **Step 5: Commit** `feat(encyclopedia): plan v2 model with assignments and redirects`. + +### Task 2: State v2 + +**Files:** +- Modify: `src/Wiki.Application/Encyclopedia/EncyclopediaState.cs` +- Test: `test/Wiki.Application.Tests/EncyclopediaStateTests.cs` + +**Interfaces:** +- Produces: `record EncyclopediaState(IReadOnlyDictionary Subjects, IReadOnlyDictionary FailedBatches, IReadOnlyDictionary KeptArticles)`. + - `FailedBatches`: fingerprint of a batch whose reply was unusable → when. + - `KeptArticles`: article slug → `SubjectFingerprint` at which the fold call answered keep. + - `static string BatchFingerprint(IReadOnlyList notes)` — SHA256 over `Prompts.Version` and sorted `slug@contentHash`. + - `PlanAttempt` and `NoteSetFingerprint` are removed; JSON with an old `failedPlan` field still reads (field ignored). + - `SubjectFingerprint(PlannedArticle, notes)` keeps its formula minus `Related` (which no longer exists), plus `Scope`. + +- [ ] **Step 1: Failing tests** — `BatchFingerprint_ChangesWhenANoteChanges`, `Read_OldFailedPlanField_Ignored`, `RoundTrip_FailedBatchesAndKept`. +- [ ] **Step 2: Run, verify fail.** +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run tests pass; solution builds.** +- [ ] **Step 5: Commit** `feat(encyclopedia): state v2 with per-batch and keep memory`. + +### Task 3: Neighbours + +**Files:** +- Create: `src/Wiki.Application/Encyclopedia/ArticleNeighbours.cs` +- Test: `test/Wiki.Application.Tests/ArticleNeighboursTests.cs` + +**Interfaces:** +- Consumes: `IEmbeddingModel` (active), `IVectorIndex.QueryAsync(modelId, vector, k, ct)`, `EmbeddingText.For(doc, 1500)`, `ArticlePlan` (Task 1). +- Produces: `sealed class ArticleNeighbours(IEmbeddingModel model, IVectorIndex index, EncyclopediaSettings settings)`: + - `Task VectorAsync(Document note, ct)` — embeds `EmbeddingText.For(note, 1500)`; caches per (slug, ContentHash) for the lifetime of the instance. + - `Task> NearbyArticlesAsync(Document note, ArticlePlan plan, ct)` — `NeighbourNotes` hits, mapped through `plan.Assignments`, summed scores, top `CandidateArticles`, ties by slug. + - `Task> NearbyArticlesAsync(PlannedArticle article, IReadOnlyDictionary notes, ArticlePlan plan, ct)` — over its first `NeighbourSources` sources, excluding itself. + - `Task> NearestAsync(Document seed, IReadOnlySet among, int n, IReadOnlyDictionary notes, ct)` — the seed plus its nearest hits that are in `among`, up to `n`; queries `max(n * 3, NeighbourNotes)` hits. +- Exceptions from the embedding model or index propagate. + +- [ ] **Step 1: Failing tests** with `FakeIndex` seeded by hand-made vectors: `NearbyArticles_SumsScoresPerArticle`, `NearbyArticles_CapsAtCandidateArticles`, `NearbyArticles_OfArticle_ExcludesItself`, `Nearest_OnlyAmongAndCapped`, `Vector_CachedPerContentHash` (`FakeEmbeddings.Batches` count). +- [ ] **Step 2–4: Run fail, implement, run pass.** +- [ ] **Step 5: Commit** `feat(encyclopedia): nearby articles through the note index`. + +### Task 4: Assign + +**Files:** +- Create: `src/Wiki.Application/Encyclopedia/AssignNotesUseCase.cs` +- Modify: `src/Wiki.Application/Encyclopedia/Prompts.cs` (add `Assign`; remove `Plan`; `Version = 6`) +- Test: `test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs` + +**Interfaces:** +- Consumes: `ITextModel.CompleteAsync(system, user, maxTokens, ct)`, `ArticlePlan`, `EncyclopediaSettings`. +- Produces: + - `record AssignOutcome(IReadOnlyList New, IReadOnlyDictionary> Assignments, bool Unusable)` — `Assignments` keys ⊆ the batch; `[]` = skip; `Unusable` when the reply was truncated, not JSON, or had no valid entry. + - `sealed class AssignNotesUseCase(ITextModel model, EncyclopediaSettings settings, ILogger log)` with `Task AssignAsync(IReadOnlyList batch, IReadOnlyList candidates, ArticlePlan plan, ct)`. +- User prompt lines: notes as `slug | title | tags | body length | first paragraph` (first paragraph ≤ 300 chars, flattened like today's `Flatten`); candidates as `slug | titleDe | category | scope | source count`. +- Reply shape: `{"new":[{"slug","titleDe","titleEn","category","scope"}],"notes":[{"note","articles":[...]}|{"note","skip":true}]}`. +- Validation: drop `new` entries with bad slug, taken slug (`plan.SlugTaken`), duplicate, unknown category, empty scope; drop note entries for notes not in the batch, duplicates, or referencing slugs that are neither candidates nor valid `new`; drop `new` articles no note references. `HttpRequestException` 400 → `Unusable`; `ModelUnavailableException` propagates. +- Prompt rules (binding content, wording free): readers and scope as in today's `Plan`; granularity rule (own article only with enough material, else parent system's article); skip only meta/tooling notes; prefer existing candidates; titles/slugs name nothing concrete (no hostnames, IDs, addresses); JSON only, compact. + +- [ ] **Step 1: Failing tests** with `FakeTextModel`: `Assign_ExistingAndNew`, `Assign_NoteOutsideBatch_Dropped`, `Assign_ArticleOutsideCandidates_Dropped`, `Assign_NewWithTakenSlug_DroppedAndItsNotesUnassigned`, `Assign_UnreferencedNew_Dropped`, `Assign_Truncated_IsUnusable`, `Assign_PromptIsBounded` (batch 20, candidates 20 ⇒ user prompt has exactly 20 note lines and ≤ 20 candidate lines regardless of `plan` size — seed the plan with 500 articles), `Assign_ModelUnavailable_Propagates`. +- [ ] **Step 2–4.** +- [ ] **Step 5: Commit** `feat(encyclopedia): assign notes in bounded batches`. + +### Task 5: Split + +**Files:** +- Create: `src/Wiki.Application/Encyclopedia/SplitArticleUseCase.cs` +- Modify: `Prompts.cs` (add `Split`) +- Test: `test/Wiki.Application.Tests/SplitArticleUseCaseTests.cs` + +**Interfaces:** +- Produces: + - `record SplitOutcome(PlannedArticle Overview, IReadOnlyList Children)`; `null` = unusable. + - `sealed class SplitArticleUseCase(ITextModel model, EncyclopediaSettings settings, ILogger log)`: `Task SplitAsync(PlannedArticle article, IReadOnlyDictionary notes, ArticlePlan plan, ct)`. + - `static bool NeedsSplit(PlannedArticle a, IReadOnlyDictionary notes, EncyclopediaSettings s)` — body chars of existing sources > `MaxSourceCharsPerArticle` or source count > `MaxSourcesPerArticle`. +- Input: the article and its first `MaxSourcesPerArticle` sources as `slug | title | tags | body length`. +- Reply: `{"overview":{"scope","sources":[...]},"children":[{"slug","titleDe","titleEn","category","scope","sources":[...]}]}`. Valid: 2–6 children, child slugs valid and not taken, every input source in overview or ≥ 1 child, sources ⊆ input. Overview keeps slug, titles, category; its sources = model's overview sources + the sources beyond the input window (in original order). + +- [ ] **Step 1: Failing tests:** `Split_Valid`, `Split_LostSource_IsNull`, `Split_OneChild_IsNull`, `Split_InputCappedAtMaxSources_RestStaysWithOverview`, `NeedsSplit_ByCharsAndByCount`. +- [ ] **Step 2–4.** +- [ ] **Step 5: Commit** `feat(encyclopedia): split oversized articles`. + +### Task 6: Fold + +**Files:** +- Create: `src/Wiki.Application/Encyclopedia/FoldArticleUseCase.cs` +- Modify: `Prompts.cs` (add `Fold`) +- Test: `test/Wiki.Application.Tests/FoldArticleUseCaseTests.cs` + +**Interfaces:** +- Produces: + - `sealed class FoldArticleUseCase(ITextModel model, EncyclopediaSettings settings, ILogger log)`: `Task FoldAsync(PlannedArticle thin, IReadOnlyDictionary notes, IReadOnlyList nearby, ct)` — target slug, or `null` for keep / unusable. + - `static bool IsThin(PlannedArticle a, IReadOnlyDictionary notes, EncyclopediaSettings s)` — sources < `FoldMinSources` and total body < `FoldMinChars`. +- Reply: `{"into":"slug"}` (must be one of `nearby`) or `{"keep":true}`. + +- [ ] **Step 1: Failing tests:** `Fold_Into`, `Fold_Keep_IsNull`, `Fold_TargetNotNearby_IsNull`, `IsThin_Thresholds`. +- [ ] **Step 2–4.** +- [ ] **Step 5: Commit** `feat(encyclopedia): fold thin articles into a neighbour`. + +### Task 7: Writer link targets + +**Files:** +- Modify: `src/Wiki.Application/Encyclopedia/WriteArticleUseCase.cs` (`IArticleWriter`, user prompt ~l.249-274) +- Test: `test/Wiki.Application.Tests/WriteArticleUseCaseTests.cs` + +**Interfaces:** +- Produces: `IArticleWriter.WriteAsync(PlannedArticle planned, IReadOnlyList sources, IReadOnlyList linkTargets, ct)` — the parameter is renamed and now carries only the bounded link targets; the "Related articles" line is removed; the "Articles you may link to" section lists `linkTargets` minus the article itself. The subject line adds `Scope`. + +- [ ] **Step 1: Failing test:** `Write_LinkListIsExactlyTheTargets` (pass 3 targets, assert the prompt lists those 3 and no other slug), update existing tests for the signature. +- [ ] **Step 2–4.** +- [ ] **Step 5: Commit** `feat(encyclopedia): writer links only to bounded targets`. + +### Task 8: Reconciler + +**Files:** +- Modify: `src/Wiki.Application/Encyclopedia/EncyclopediaReconciler.cs`, `src/Wiki.Application/Encyclopedia/PlanArticlesUseCase.cs` (delete; move `EncyclopediaSettings` to `EncyclopediaSettings.cs`), `src/Wiki.Host/Composition/WikiOptions.cs`, `src/Wiki.Host/Program.cs`, `src/Wiki.Host/Api/ApiV1.cs` +- Test: `test/Wiki.Application.Tests/EncyclopediaReconcilerTests.cs` (rewrite planning tests; delete `PlanArticlesUseCaseTests.cs`), `test/Wiki.Host.Tests/EncyclopediaEndpointTests.cs` + +**Interfaces:** +- Consumes: Tasks 1–7. +- Produces: + - `EncyclopediaSettings` with the fields of Global Constraints (positional record; update every `new EncyclopediaSettings(...)`). + - `ReconcileSummary(int Planned, int Assigned, int Split, int Folded, int Written, int Deleted, int Rejected, int Remembered, bool StoppedByModel, string? Skipped, string? Commit)`; `Planned` = article count after the run. + - Reconciler takes `ArticleNeighbours`, `AssignNotesUseCase`, `SplitArticleUseCase`, `FoldArticleUseCase` in place of `PlanArticlesUseCase`. DI: `ArticleNeighbours` gets the active `IEmbeddingModel`. +- Run order (spec "A run"): freshness gate → load plan (`ArticlePlanJson.Read`, `null` ⇒ `ArticlePlan.Empty()`) and state → diff (removed notes: `plan.RemoveNote`, collect deleted articles) → assign loop: while new notes remain and batches < `MaxAssignBatchesPerRun`: seed = first new note (ordinal slug order) not yet assigned or tried this run; batch = `NearestAsync(seed, untried, AssignBatchSize)`; if `BatchFingerprint(batch)` is in `FailedBatches`, mark the batch's notes tried and continue without a call (does not count against the cap); candidates = union of `NearbyArticlesAsync(note)` over the batch (dedupe, cap `CandidateArticles * 2`) resolved against the *current, already-updated* plan; apply outcome (`AddArticle`, `Assign`) or remember `FailedBatches[fp]` and mark the batch's notes as tried this run → split loop over `NeedsSplit` articles, biggest first, ≤ `MaxSplitsPerRun` → fold loop over `IsThin` articles not in `KeptArticles` at their current fingerprint, ≤ `MaxFoldsPerRun` → stale/due/write as today, with `linkTargets` = `NearbyArticlesAsync(article)` resolved to articles, plus split children for an overview → delete files of articles no longer in the plan (removed or folded) → stage `_plan.json` if changed → `FinishAsync`. + - `HttpRequestException` from neighbours/embeddings: log, stop the assign/split/fold steps, continue with writing. `ModelUnavailableException` anywhere: stop, commit staged, `StoppedByModel = true`. + - Endpoint (`ApiV1.cs`): 502 also when `summary.Planned == 0`. + - `WikiOptions.EncyclopediaOptions`: add the new fields with defaults, remove `PlanMaxTokens`. + +- [ ] **Step 1: Failing tests** (`EncyclopediaReconcilerTests`, `FakeTextModel` scripted per call; `FakeIndex`/`FakeEmbeddings` seeded through the real `SyncUseCase` as today): `Run_Bootstrap_LaterBatchesSeeEarlierArticles` (40 notes, batch size 20: second assign prompt lists the article the first created), `Run_OnlyNewNotesAreAssigned` (second run with one new note ⇒ one assign call), `Run_RemovedNote_DropsSourceAndDeletesEmptyArticle`, `Run_UnusableBatch_RememberedAndNotRetried` (second run, unchanged notes ⇒ no assign call), `Run_SplitThenWriteOverviewWithChildrenAsLinks`, `Run_FoldDeletesFilesAndAddsRedirect`, `Run_KeptArticle_NotAskedAgain`, `Run_EmbeddingsDown_StillWritesStale` (Review Focus 5), `Run_StepCapsRespected` (`MaxAssignBatchesPerRun` 1 ⇒ one call), `Run_ModelUnavailable_CommitsStaged`. Endpoint: `Run_EmptyPlan_Is502`. +- [ ] **Step 2–4.** +- [ ] **Step 5: Commit** `feat(encyclopedia): reconcile through bounded assign, split and fold`. + +### Task 9: Redirects in the host + +**Files:** +- Modify: `src/Wiki.Application/Reading/ArticleQueries.cs`, `src/Wiki.Host/Components/Pages/HouseArticle.razor`, `src/Wiki.Host/Rendering/ArticleMarkdown.cs`, `src/Wiki.Host/Program.cs` (if DI changes) +- Test: `test/Wiki.Host.Tests/HouseTests.cs`, `test/Wiki.Host.Tests/ArticleMarkdownTests.cs` + +**Interfaces:** +- Produces: + - `ArticleQueries(IDocumentCatalog catalog, INoteStore store)` gains `Task> RedirectsAsync(ct)` — reads `articles/_plan.json` at the catalog's commit via `store.ReadTextAsync`, parses with `ArticlePlanJson.Read`, caches per commit; empty when missing or unreadable. And `Task ResolveAsync(string slug, ct)` = the plan's `Resolve` semantics over those redirects (≤ 5 hops, cycles ⇒ `null`); a slug that is itself an article resolves to itself. + - `HouseArticle.razor`: when `GetAsync(Lang, Slug)` is null, resolve; if the final target exists in `Lang` and is visible to the requester (same checks as a direct request), respond 301 to `/house/{Lang}/{target}`; otherwise the usual 404. + - `ArticleMarkdown.Render(..., IReadOnlyDictionary visibleTitles, IReadOnlyDictionary redirects, ...)`: a `[[slug]]` not in `visibleTitles` is resolved through `redirects` (≤ 5 hops) before being dropped; the link `href` is the target. + +- [ ] **Step 1: Failing tests:** Host `Redirect_ToVisibleTarget_Is301`, `Redirect_ToPrivateTarget_Anonymous_Is404` (same body/status as a missing slug), `Redirect_Chain_Followed`, `Redirect_Cycle_Is404`; Markdown `WikiLink_ToFoldedSlug_PointsAtTarget`, `WikiLink_RedirectToInvisible_Dropped`. +- [ ] **Step 2–4.** +- [ ] **Step 5: Commit** `feat(house): follow article redirects`.