diff --git a/docs/internal/modules.md b/docs/internal/modules.md index 9e63d71..faa5a7c 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -50,6 +50,23 @@ Reference material may inform a module or prompt but must not become source evidence. The resolver and materializer behavior is described in [Pipeline Internals](pipeline.md#reference-materialization). +## Domain Reference Data + +### `internal/modules/dnd/spells/catalog` + +The spell catalog package owns the embedded, versioned D&D 5e 2014 SRD spell +reference data. Its strict JSON asset contains one canonical record per spell, +including spell level and all applicable class memberships. `LoadSRD5E2014` +validates catalog identity, provenance metadata, ordering, uniqueness, levels, +classes, aliases, and lookup-key collisions before exposing immutable copies. + +Lookup is case-insensitive and normalizes whitespace and common apostrophe +variants while preserving source punctuation in canonical display names. The +catalog contains 319 unique spells and 779 class memberships. Source and +license details live beside the asset in `SOURCES.md`. This domain-owned data is +separate from `internal/modules/dnd/shared`, which is reserved for reusable +prompt and source-reference machinery. + ## Input Adapter ### `internal/modules/seriatim/input/transcript` diff --git a/docs/internal/overview.md b/docs/internal/overview.md index d3f34db..94bc8da 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -87,6 +87,7 @@ Configuration. The implemented module packages are: | `internal/modules/dnd` | Owns the canonical D&D spell-list and spell-cast artifact types. | | `internal/modules/dnd/codec/spells` | Strictly decodes and stably encodes the durable D&D spell-list representation. | | `internal/modules/dnd/extract/spells` | Maps private structured model output to canonical source-grounded D&D spell lists. | +| `internal/modules/dnd/spells/catalog` | Embeds and validates the versioned D&D 5e 2014 SRD spell catalog and provides immutable lookup. | | `internal/modules/generic/merge/appendorder` | Combines accepted extraction results in chunk order. | | `internal/modules/generic/normalize/noop` | Preserves accepted merged output. | | `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, and rejections as logical JSON files. | diff --git a/docs/roadmap/dnd-spell-extraction.md b/docs/roadmap/dnd-spell-extraction.md new file mode 100644 index 0000000..094004a --- /dev/null +++ b/docs/roadmap/dnd-spell-extraction.md @@ -0,0 +1,155 @@ +# D&D Spell Extraction Quality + +## Status + +The immediate baseline feature is accepted and pending implementation. +Feedback-aware repair and semantic LLM validation are deferred until the +baseline has been evaluated. + +## Objective + +Improve the precision and consistency of `dnd/spells` extraction by first +grounding the LLM in an effective spell catalog composed from the embedded SRD +5.1 catalog and configured overlays, rejecting invalid catalog names +deterministically, and measuring the existing retry mechanism. Preserve a clear +separation between response shape, domain policy, retry orchestration, and later +semantic review. + +## Accepted Baseline Scope + +### Ground extraction with a catalog input + +Provide the spell catalog to the extraction prompt as a separate input rather +than embedding all spell names as an enum in the private LLM response schema. +The response schema should continue to define transport shape, while the +catalog remains the single source of truth for recognized spell names. + +Build the prompt input from the effective catalog. Include canonical names +only; aliases remain available to deterministic lookup but do not belong in the +prompt. Omit levels and class memberships unless evaluation demonstrates that +they improve extraction. Instruct the model to return canonical catalog names +and to use reference material only for disambiguation, never as source +evidence. + +The effective catalog must support configured overlays from the first +implementation so non-SRD, sourcebook, and homebrew spells can participate in +the same grounding and validation path. Overlay composition must validate +canonical names, aliases, duplicates, conflicts, and provenance +deterministically. The prompt and validator must consume the same resolved +catalog; configuration and catalog identity must participate in effective +pipeline and checkpoint identity wherever they can change accepted output. + +This design avoids duplicating the catalog in a schema asset and allows +deterministic validation to produce a specific diagnostic for an unknown name. + +### Add deterministic catalog validation + +Add a typed deterministic validator for extracted spell names and include it in +the production default chain after shape validation. It should use the catalog +lookup rules so case, repeated whitespace, and supported apostrophe variants do +not cause false rejections. + +The validator should validate only; canonical display-name replacement belongs +in the future D&D spell normalizer. It must not silently discard an individual +unknown cast from an otherwise valid extraction result. + +The initial target chain is: + +```yaml +validators: + - generic/valid_json + - generic/valid_json_schema + - extract/dnd/spells/shape + - extract/dnd/spells/catalog + - extract/dnd/spells/source_refs + - extract/dnd/spells/source_relatedness +``` + +A name absent from the effective base-plus-overlay catalog should reject the +extraction result. Overlay support must not weaken this policy implicitly. + +### Measure ordinary extraction retries + +Configure the spell extraction binding with `retries: 2`. This means one +initial extraction attempt plus at most two additional attempts for a module +error, validator error, or validator rejection. The retry wraps extraction and +the complete extraction validator chain. + +Keep the framework-wide retry default at zero. The two-retry policy is specific +to the spell extraction workflow and should not silently apply to deterministic +stages or future LLM modules with different cost and failure characteristics. + +The baseline deliberately retains current retry behavior: each retry repeats +the same extraction request without the rejected candidate or validator +diagnostic. Measure this behavior before introducing corrective prompts so the +effects of catalog grounding, deterministic validation, and later repair-aware +retry can be distinguished. + +### Baseline evaluation policy + +Exercise the grounded extractor against a small human-reviewed transcript set. +Record false positives, false negatives, unknown-name rejections, retry +outcomes, acceptance by attempt, and model-call cost. Preserve representative +cases that can compare ordinary retry with a future repair-aware strategy. + +Do not add an LLM validator during this milestone. Finite catalog membership is +deterministic, and semantic LLM validation needs evidence of a specific failure +mode before its extra cost and nondeterminism are justified. + +## Target-State Acceptance Criteria + +- The extraction prompt receives canonical names from the effective catalog + without maintaining a second handwritten spell list or schema enum; aliases + are excluded from the prompt. +- Configured overlays compose deterministically with the embedded catalog, and + the extractor and validator consume the same effective catalog. +- Every accepted spell name resolves through the effective catalog, including + aliases recognized only by deterministic lookup. +- Unknown spell names produce a scoped deterministic rejection and can consume + the configured extraction retry budget. +- Two retries result in no more than three extraction calls for a rejected + chunk, excluding any later separately configured LLM validator calls. +- Discarded-attempt warnings are not promoted, and an exhausted rejection + remains a rejected pipeline outcome under existing runner semantics. +- The default production validator chain, maintained examples, and applicable + current-behavior documentation are updated alongside implementation. + +## Deferred Retry And Validation Work + +Reassess the following only after the baseline evaluation is available: + +1. Add bounded, field-specific structured validation issues rather than relying + only on a reason code and free-form message. Validators should report all + related issues they can safely identify in one pass. +2. Classify retryable outcomes. Provider failures and actionable validation + rejections may retry; cancellation must stop; configuration, schema-loading, + and internal invariant failures should fail without spending more LLM calls. +3. Add an optional typed repair capability to the extractor contract. The + framework should transport the rejected candidate and diagnostics without + constructing a domain prompt; each module should own its repair prompt. +4. Evaluate a hybrid two-retry policy: first repair the rejected candidate with + structured feedback, then use a fresh extraction without the candidate if + repair is still rejected. Compare it with repeated repair and the ordinary + retry baseline. +5. Consider a narrowly scoped LLM validator only if human review demonstrates + semantic failures that deterministic checks cannot resolve, such as + distinguishing a true cast from discussion, intent, table chatter, or an + effect inferred from general D&D knowledge. +6. Implement catalog-aware name canonicalization in the D&D normalizer after + catalog-validator behavior is stable; this is separate from retry repair. +7. Define the measurements and thresholds that would justify feedback-aware + repair or semantic LLM validation. The immediate baseline records evidence + but does not need to establish those gates. + +Repair responses should remain complete extraction replacements rather than +patches. Diagnostics must be treated as bounded data, particularly if a future +LLM-backed validator can contribute their text. + +## Resolved Scope Decisions + +- Catalog overlays are part of the immediate implementation, not a later + extension. +- Prompt grounding includes canonical spell names only. Aliases participate + only in deterministic lookup and validation. +- Measurements and decision thresholds for feedback-aware repair and semantic + LLM validation are deferred future work and do not block the baseline. diff --git a/docs/roadmap/future.md b/docs/roadmap/future.md index f1475bf..78e9e9a 100644 --- a/docs/roadmap/future.md +++ b/docs/roadmap/future.md @@ -2,49 +2,138 @@ Current Notarius behavior is documented in the canonical README, CLI, configuration, operations, internal, and integration docs. This roadmap records -future work only. +future work only. Items are ordered roughly by current value and specificity, +not as committed release dates. -## Candidate Product Work +## Near-Term D&D Pipeline + +### Solidify Spell Extraction + +- Implement the immediate overlay-capable catalog-grounding and + deterministic-validation baseline in + [D&D Spell Extraction Quality](dnd-spell-extraction.md), using ordinary + `retries: 2` behavior as the comparison point. +- After evaluating that baseline, reconsider the roadmap's deferred structured + diagnostics, retryability classification, repair-capable extractor contract, + hybrid repair/fresh retry policy, and narrowly scoped semantic LLM + validation. +- Replace the no-op spell normalizer with a D&D-specific implementation that + canonicalizes recognized spell names, performs only high-confidence repairs, + retains uncertain values for review, and emits scoped warnings. +- Conservatively collapse duplicate spell casts when canonical spell, caster, + and source evidence establish that they represent the same event. Merge and + canonicalize their source references rather than relying on spell name alone. +- Maintain a small set of human-reviewed transcripts and outputs for prompt, + validator, and normalizer development. Treat model-quality review as an + iterative human evaluation aid, not a deterministic correctness gate. + +### Add Sequential D&D Artifacts + +- Add NPC extraction, including identity, aliases, descriptions, relationships, + and source evidence suitable for use as later reference material. +- Add combat-turn extraction with explicit event and source-reference + semantics. Use earlier NPC output as a reference to improve participant + identity and consistency. +- Add narrative extraction for scene summaries, party actions, and NPCs + encountered when that output proves useful beyond the dedicated NPC artifact. +- Define the preferred operational sequence for independent pipelines on the + same transcript. The initial direction is NPCs first, followed by spells and + combat turns as appropriate, with earlier JSON artifacts supplied to later + runs as references. +- Keep this sequencing operator- or script-driven initially. Do not require a + general DAG or concurrent cross-lane reconciliation model. + +### Improve D&D Scene Classification + +- Extend scene annotations with classifications that downstream extractors can + use, including reliable combat and narrative indicators. +- Strengthen the scene prompt so every scene containing combat turns is marked + as combat, and add validation capable of detecting missing or inconsistent + combat classifications. +- Allow the combat extractor to no-op for chunks that are not classified as + combat, avoiding unnecessary model calls where practical. +- Allow a narrative extractor to select the corresponding scene classification + rather than processing every chunk indiscriminately. +- Reassess whether one shared scene plan provides enough context for NPC, + spell, combat, and narrative pipelines after these extractors have real-world + usage. Add more complex chunking only in response to demonstrated failures. + +## Shared Normalization And Quality Work + +### Generic LLM-Assisted Deduplication + +- Add a reusable normalizer that asks an LLM to identify duplicate sets in a + list and propose one replacement element for each set. +- Define the minimum domain-neutral input contract, initially an ordered list + whose elements have stable unique IDs. Artifact-kind registrations or + adapters may expose that structure without moving domain rules into the + generic package. +- Keep mutation deterministic: parse and validate the model's duplicate groups, + require every referenced ID to exist, reject overlapping or malformed groups, + prevent unrelated insertion or deletion, and apply only approved replacement + operations in code. +- Preserve provenance needed for audit and downstream validation, and emit + warnings describing every collapsed group. +- Evaluate batching and context-window limits before applying the normalizer to + large artifact collections. + +The model may use its own domain knowledge to judge semantic duplication; the +generic implementation is responsible only for the common proposal contract, +safety checks, and deterministic application of accepted changes. + +### Validation And Review + +- Add domain validators and production default chains alongside each new D&D + artifact. +- Add production LLM-backed validators only when a concrete review policy + benefits from model judgment and deterministic checks are insufficient. +- Add validator diagnostics and timing summaries if operators need more detail + than the current [durable output bundle](../integrations/json-output.md) + provides. +- Add validator compatibility metadata if deployments need config-time proof + that a validator is suitable for a particular stage, module, or artifact + kind. +- Add media-type validators when non-JSON artifact representations are + introduced. + +## Reference And Sequential-Pipeline Evolution + +- Make prior-run artifacts easier to bind as references without changing the + existing module-facing reference-item contract. +- Add structured or parsed references, such as typed NPC registries, rosters, + or spell catalogs, when opaque UTF-8 prompt material is no longer sufficient. +- Add per-slot or per-chunk inclusion policies so large references are not + repeated in every prompt unnecessarily. +- Add token budgeting and model context-window management for reference + content. +- Add reference caching, preprocessing, summarization, embedding, or retrieval + only when reference size and observed model behavior justify them. +- Consider non-file reference producers for prior-run artifacts, derived + summaries, or entity registries after manual sequential composition becomes + burdensome. + +## Blue-Sky Platform And Operations + +These ideas are intentionally less specified. Promote one into an earlier +section only after a concrete workflow, contract, and priority emerge. + +### Platform Extensions - Additional input adapters, such as Markdown or note-export formats. -- Additional D&D extractors beyond spell casts. -- Add non-file reference producers, such as prior-run artifacts, derived - summaries, or entity registries, without changing module-facing reference - item contracts. -- Add token budgeting and model context-window management for reference content. -- Add per-slot or per-chunk inclusion policies so modules can avoid repeating - large reference content in every prompt when that becomes important. -- Add structured or parsed references, such as typed roster schemas, when a - module has a clear need for more than opaque UTF-8 text. -- Add reference caching, preprocessing, summarization, embedding, or retrieval - if references become large enough to require preprocessing. -- Cross-lane entity normalization. -- Cross-chunk semantic deduplication. -- Additional validator packages and production default chains for future - modules. -- Production LLM-backed validators when there is a concrete review policy that - benefits from model judgment. -- Validator diagnostics and timing summaries if operators need more detail than - the current [durable output bundle](../integrations/json-output.md) provides. -- Media-type validators for non-JSON module outputs when such modules are - introduced. -- Validator compatibility metadata if real deployments need config-time - enforcement that a validator is suitable for a specific stage or module. -- Batching or context-window controls for LLM-backed validators if validator - inputs become large enough to require them. - Additional output encoders. +- Concurrent cross-lane entity normalization or broader workflow composition. +- Batching or specialized context-window controls for LLM-backed validators. -## Candidate Operational Work +### Distribution And Operations - Packaged release artifacts for alpha distribution. - A documented versioning and release process. -- Optional generated example output fixtures with a regeneration procedure. -- Additional diagnostics or reporting views if operator workflows need them. +- Optional generated example-output fixtures with a regeneration procedure. +- Additional diagnostics or reporting views. -## Candidate Workspace Work +### Workspace And Storage - Default-idempotent run behavior with an explicit force override. - Remote workspace storage. -- Workspace garbage collection. -- Workspace archival policy. +- Workspace garbage collection and archival policies. - Cross-machine checkpoint reuse. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 32d6b30..56c178a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,488 +1,457 @@ -# Test Suite Policy Review Implementation +# D&D Spell Extraction Quality Implementation Plan ## Status -Completed on 2026-07-18 by the commit series ending with `4f96abf`. This -document is retained as the execution record for the completed test-suite -policy review; its stages are no longer active implementation instructions. +Ready for implementation. Follow the stages in order. This plan covers the +accepted baseline in [D&D Spell Extraction Quality](dnd-spell-extraction.md), +not its deferred repair-aware retry or semantic LLM-validation work. -## Purpose +This active plan replaces the completed test-suite implementation record that +previously occupied this filename. That review remains documented in +[Test Suite Policy Review](tests.md) and repository history; its stages are not +instructions for this feature. -This document records the decision-complete execution plan used to implement -the target revisions in [Test Suite Policy Review](tests.md). The stages were -followed in order. The feature roadmap owns the review evidence, desired test -ownership, required dispositions, and target state; this document records the -sequencing, file-level work, validation, and stop conditions used during -implementation. +## Objective -This is test-suite harmonization, not a production feature change. Do not -modify production behavior. If a new durable test exposes incorrect production -behavior, preserve the failing evidence, stop the affected stage, and report -the defect separately for explicit scoping. +Implement an overlay-capable effective spell catalog, ground every +`dnd/spells` extraction request with its canonical names, reject extracted +names that do not resolve through that catalog, and establish the existing +same-request behavior with `retries: 2` as the measured baseline. -## Governing Policies +The completed feature must fail malformed catalog configuration before source +parsing or any LLM call, keep aliases out of the prompt, preserve the current +private LLM response schema, and use one effective catalog policy for both the +extractor and validator. -Before implementation, read and follow: +## Governing Documents -- [Testing Policy](../policy/testing.md), especially behavioral ownership, - semantic duplication, test doubles, exact diagnostics, and deletion criteria; -- [Architecture](../policy/architecture.md), especially dependency direction, - typed artifact boundaries, validation ownership, checkpoint safety, and - source/reference separation; and -- [Documentation Policy](../policy/documentation.md), especially canonical - ownership, current-versus-future behavior, and maintenance of test-routing - links. +Before changing code, read and follow: -The following constraints apply to every stage: +- [D&D Spell Extraction Quality](dnd-spell-extraction.md) for accepted product + intent, target state, and deferred work; +- [Architecture](../policy/architecture.md) for dependency direction, + preparation, domain ownership, validation, reference provenance, and LLM + boundaries; +- [Documentation Policy](../policy/documentation.md) for canonical document + ownership and current-versus-future behavior; +- [Testing Policy](../policy/testing.md) for behavioral test ownership and + offline/default-suite requirements; +- [Pipeline Internals](../internal/pipeline.md), [Module Internals](../internal/modules.md), + and [LLM Runtime](../internal/llm.md) for the implementation boundaries being + changed; and +- [Configuration](../config.md) and the existing D&D integration contracts for + current user-visible contracts. -- preserve unrelated user changes in a dirty worktree; -- make no production-code changes unless separately authorized after reporting - a confirmed production defect; -- add no coverage-percentage target and do not use line coverage as a success - metric; -- do not mechanically convert tests to tables, consolidate leaf registration - tests, or introduce new test frameworks; -- keep the default suite deterministic, offline, credential-free, and safe for - repeated and parallel execution; -- use real filesystem collaborators with `t.TempDir()` for checkpoint behavior; -- assert typed/structured outcomes or stable category fragments rather than - complete incidental error wording; and -- do not update fixtures automatically or add a golden-update path. +Preserve unrelated worktree changes. Do not implement anything listed under +the feature roadmap's deferred retry and validation section. -## Stage 0 - Establish the implementation baseline +## Fixed Design Decisions -1. Read [Test Suite Policy Review](tests.md) completely, including the retained - ownership map and every required finding. -2. Inspect the worktree and preserve unrelated changes. Limit planned edits to - tests and the internal documentation routing identified below. -3. Run: +### Configuration and overlay transport + +Add one optional extract-reference slot named `spell_catalog` to `dnd/spells`. +It accepts one UTF-8 `application/json` file with a maximum size of 1 MiB. Use +the normal reference binding and materialization path, so configuration-relative +paths, CLI overrides, redaction, provenance, and checkpoint reference digests +continue to work without a D&D-specific filesystem reader. + +The slot binds an overlay bundle rather than one catalog. One file can therefore +carry multiple sourcebook, campaign, or homebrew catalogs without adding list +values to the general configuration reference schema. The canonical lane-local +form is: + +```yaml +artifacts: + spells: + extract: + module: dnd/spells + retries: 2 + references: + spell_catalog: ./campaign-spells.json +``` + +The existing pipeline-level reference-default behavior may also bind the slot. +Do not add module options, environment variables, or a new CLI flag for catalog +overlays. + +### Overlay bundle contract + +Define and document this strict JSON shape as version 1: + +```json +{ + "schema_version": "notarius.dnd.spell-catalog-overlay.v1", + "catalogs": [ + { + "id": "campaign.example", + "ruleset": "dnd-5e-2014", + "source": { + "title": "Example campaign spells", + "version": "1", + "url": "", + "license": "" + }, + "spells": [ + { + "name": "Aegis of Emberfall", + "aliases": ["Emberfall Aegis"] + } + ] + } + ] +} +``` + +Use `encoding/json` with unknown-field rejection and reject trailing JSON +values. Require `schema_version` and `catalogs` at the bundle level; +`id`, `ruleset`, `source`, and `spells` on each catalog; `title` on each source; +and `name` on each spell. Require the exact schema version, at least one +catalog, unique trimmed nonempty catalog IDs, the `dnd-5e-2014` ruleset, a +trimmed nonempty source title, at least one spell per catalog, and trimmed +nonempty canonical names. `source.version`, `source.url`, and `source.license` +are optional strings and may be empty so private homebrew material is not +forced to claim a publication or license. `aliases` may be omitted or supplied +as an array, but it must not be JSON `null`, and every supplied alias must be +trimmed and nonempty. The integration contract must state that catalog content +is contextual domain data and never source evidence for a cast. + +Overlay spells intentionally carry names and aliases only. Do not invent +unknown spell levels or class memberships, and do not weaken the richer +embedded SRD catalog contract to accommodate overlay-only recognition data. + +### Effective catalog composition + +Keep the existing embedded SRD catalog and introduce a separate immutable +effective recognition-catalog type in `internal/modules/dnd/spells/catalog`. +It must expose defensive-copy access to globally sorted canonical names, +normalized lookup returning the canonical display name, the ruleset, overlay +IDs, and a deterministic semantic digest. + +Compose the effective catalog using these rules: + +1. Start with every canonical SRD name and its aliases. +2. Sort overlay catalogs by ID before composition; file order must not choose a + winner. +3. Normalize lookup keys using the package's existing case, whitespace, and + apostrophe rules. +4. A new canonical key adds a spell. An overlay canonical key equal to an + existing canonical key augments that same spell; retain the established + canonical display spelling and merge aliases. +5. If equal normalized canonical keys have different display spellings, reject + the bundle instead of selecting one by order. +6. Repeated aliases mapping to the same canonical spell are idempotent. + Canonical-name or alias collisions mapping to different spells are errors, + including collisions with the embedded catalog. +7. Return canonical names and aliases in deterministic sorted order regardless + of input ordering. Do not expose mutable backing slices or maps. + +Compute the effective-catalog digest from a canonical semantic representation +that includes the base catalog identity, ruleset, sorted overlay identity and +source metadata, and sorted effective canonical-name/alias mappings. Formatting +or object-key order alone must not change this semantic digest. Raw reference +digests continue to protect checkpoint identity when overlay file bytes change. + +### Construction-time reference availability + +Extend `pipeline.BuildRequest` with a defensively cloned +`contracts.ReferenceSet`. `pipeline.Prepare` must provide the materialized +references belonging to each constructed stage implementation and to every +validator in that stage's chain: + +- chunk references to the chunker and chunk validators; +- lane extract references to the extractor and extract validators; +- lane merge references to the merger and merge validators; and +- lane normalize references to the normalizer and normalize validators. + +Input and output receive an empty set because they cannot declare references. +Keep runtime request references unchanged. Construction-time references exist +so immutable modules can validate and retain derived data before source parsing; +runtime references remain available for run operations and validator context. +Do not put D&D catalog types into framework contracts or shared module +dependencies. + +Both the spell extractor and catalog validator must construct their immutable +effective catalog from the same `spell_catalog` reference bytes. Centralize +overlay selection and catalog composition in the D&D catalog package so the two +implementations cannot drift. Reject more than one materialized item in the +slot even though current configuration produces only one. + +### Prompt grounding + +Add one required structured-completion input named `spell_catalog` with media +type `application/json`. Its generated content is exactly a small object whose +`spell_names` value is the globally sorted array of effective canonical names. +Do not include aliases, levels, classes, source metadata, raw overlay JSON, or +spell descriptions. + +Add a package-owned prompt fragment that presents this input as the allowed +spell-name catalog. Update task instructions to require canonical catalog names +and prohibit returning names absent from the catalog. Keep campaign references +separate and retain the rule that references cannot establish that a cast +occurred. Do not add an enum to `dnd_spells_llm.v1.json` and do not change the +durable `dnd/spell-list` artifact schema. + +Use the digest of the generated prompt-input bytes as its input digest. Extend +extractor manifest metadata with the effective catalog semantic digest, base +catalog ID, and sorted overlay IDs; do not expose catalog content or local file +content in manifests or debug summaries. Existing reference provenance owns +overlay origin URI, media type, byte size, and raw digest. + +### Deterministic validation + +Add typed validator key `extract/dnd/spells/catalog` under +`internal/modules/dnd/validate/spells/catalog`. Construct it from the effective +catalog at preparation time and give it no module options or LLM profile. + +If spell shape is invalid, approve without catalog diagnostics so the shape +validator remains the owner of shape rejection. Otherwise, check every +nonempty spell name through normalized effective-catalog lookup. Approve +canonical names and aliases without mutating the artifact. If any names are +unknown, reject the complete extraction result with stable reason code +`unknown_spell` and a bounded message identifying all affected spell indices +and names. Sort diagnostics by artifact index and cap displayed issues at 20, +reporting the number omitted. Do not silently delete casts, convert aliases to +canonical names, or emit future structured validation issues in this feature. + +Register the validator immediately after `extract/dnd/spells/shape` in the +production default chain. Explicit validator overrides remain authoritative. + +### Retry baseline + +Make no runner, retry, rejection, or validation-result contract changes. The +maintained production example continues to set `extract.retries: 2`, meaning +one initial extraction plus at most two identical-request attempts around the +complete validator chain. Rejections exhausted after three attempts remain +nonfatal rejected outputs under current runner semantics. + +Do not add repair prompts, previous candidates, rejection feedback, structured +validation issues, error retryability classification, or an LLM validator. + +## Stage 0 — Baseline and scope control + +1. Read the governing documents and inspect the current worktree. Record and + preserve changes not owned by this plan. +2. Confirm that the embedded catalog loads 319 spells and 779 class + memberships and that the maintained production example already contains + `extract.retries: 2`. +3. Run the baseline commands: ```sh go test ./... go vet ./... go build ./cmd/notarius - go test -race ./internal/framework/pipeline ./internal/framework/llm ./internal/cli ./internal/modules/integration ``` -4. If the baseline fails for a reason unrelated to the planned work, stop and - report it. Do not rewrite expectations merely to obtain a clean baseline. - -Stage 0 is complete when the starting state and any pre-existing worktree -changes are recorded and all baseline commands pass. - -## Stage 1 - Protect filesystem checkpoint compatibility and recovery - -Complete this stage before deleting or consolidating existing tests so the -highest-risk protection is strengthened first. - -### 1.1 Checkpoint identity - -Create `internal/framework/checkpoint/identity_test.go`. Test through -`NewIdentity` and `Identity.RelativePath`, not private normalization helpers. - -Build one representative `pipeline.ResolvedPipeline` with two artifact lanes, -an input module, stable pipeline ID/digest, selected lanes, runtime -fingerprints, reference provenance, and provenance fingerprints. Add tests that -prove: - -- reordering selected lanes, runtime fingerprints, references, provenance - fingerprints, or resolved lanes does not change the resulting identity; -- duplicate or blank selected-lane entries do not change the normalized lane - set, and blank fingerprint entries do not change the identity; -- changing each meaningful identity input independently changes the digest: - pipeline ID or digest, input key, raw/source digest, selected lane set, - runtime override value, reference digest or target identity, and provenance - fingerprint; -- an omitted explicit input key falls back to the resolved input module; -- missing pipeline ID, pipeline digest, input key, and both source/input digests - are rejected with stable category context; and -- `RelativePath` is deterministic, slash-separated, relative, confined, and - includes the normalized pipeline/input and digest-derived hierarchy without - asserting incidental private prefix lengths beyond the documented layout. - -Use independent inputs for each mutation so one case cannot pass because a -different field also changed. - -### 1.2 All-stage recorder/loader round trips - -Create `internal/framework/checkpoint/filesystem_test.go`; leave the existing -schema-identifier test in `recorder_test.go`. - -Using one real root and one identity, exercise the exported recorder and loader -for: - -- source success with a valid document, self-references, metadata, and digest; -- extract success with at least one serialized artifact, schema identity, - content metadata, chunk provenance, warnings, dependency fingerprints, and - one rejected output so `StatusSucceededWithRejections` is round-tripped; -- merge success with one serialized artifact, warnings, and dependencies; and -- normalize success with one serialized artifact, warnings, and dependencies. - -For each stage, assert the loader returns `Reused`, restores the meaningful -values, and preserves serialized bytes and codec identity. Mutate the original -inputs after recording and mutate one loaded result before reloading; neither -mutation may alter persisted or subsequently loaded state. - -Inspect representative created directories and files to retain the `0700`/ -`0600` permission contract on platforms where Unix permission bits are -meaningful. Do not snapshot the full directory tree or complete JSON documents. - -### 1.3 Invalid and incompatible checkpoint state - -Seed valid state through the recorder, then copy or edit one artifact per -subtest. Drive every case through the exported loader method for that stage. -Require a non-reused decision and a short category fragment for: - -- missing artifact and malformed JSON; -- `WorkspaceSchemaVersionV1` and an unknown workspace schema version; -- mismatched checkpoint identity digest; -- wrong stage, lane, module, terminal status, or dependency fingerprint; -- incomplete serialized artifact kind/schema/schema digest; -- malformed base64 and content-digest mismatch; -- source document validation or source/output digest mismatch; and -- extract, merge, and normalize output-digest mismatch. - -Include one successful `StatusSucceededWithRejections` extract case and prove -that non-reusable running, failed, pending, or invalidated statuses remain -non-reused. Assert categories, not complete sentences. Missing state should be -a normal non-reuse decision; corrupt or incompatible state must never panic or -silently reuse. - -### 1.4 Stage validation - -Run: - -```sh -go test ./internal/framework/checkpoint ./internal/framework/pipeline ./internal/cli -go test -race ./internal/framework/checkpoint ./internal/framework/pipeline ./internal/cli -``` - -Stage 1 is complete when identity selection, every persisted stage, mutation -ownership, compatibility rejection, and corrupt-state recovery are protected at -the real filesystem boundary without production changes. - -## Stage 2 - Remove fake-only, obsolete, and misleading tests - -### 2.1 Framework contracts and helpers - -In `internal/framework/contracts/contracts_test.go`, delete exactly: - -- `TestFakeExtractorReturnsTypedOutput`; -- `TestFakeChunkerReturnsSourcePlan`; -- `TestFakeChunkerReceivesPerRunContext`; -- `TestFakeExtractorReceivesChunkAndAmbientContext`; -- `TestFakeMergeNormalizeAndOutputContracts`; and -- `TestReferenceSetDataTypes`. - -After removing those tests, delete fake methods/types or imports only when they -have no remaining test use. Retain the compile-time interface assertions and -the reference/material/artifact clone and JSON non-leakage tests. - -Delete `TestHelpersReturnValidationResults` from -`internal/framework/validate/validate_test.go`. Retain `TestApproved` and -`TestRejectedTrimsReasonAndMessage`. - -Delete `internal/modules/generic/normalize/noop/typed_test.go`. Do not remove the -no-op normalizer's resolver, registration, runner, production-composition, or -maintained-example coverage. Retain the direct append-order merger ordering -test. - -### 2.2 Obsolete Scriptorium grounding - -Delete `internal/framework/llm/scriptorium_api_test.go` in full. Do not move its -unused API inventory elsewhere. Retain and run `scriptorium_client_test.go`, -`asset_registry_test.go`, module-local prompt preparation, cancellation, -validation, profile, and credential-redaction tests. - -### 2.3 Misleading D&D integration tests - -In `internal/modules/integration/dnd_spells_runner_test.go`, delete exactly: - -- `TestRunnerDoesNotExtractSpellMentionedOnlyInPartyReference`; -- `TestRunnerCarriesDNDSpellCastWithInvalidSourceRefToSerializedOutput`; and -- `TestRunnerRejectsMalformedDNDSpellsArtifactAtSerializationBoundary`. - -Remove helpers/imports only if unused afterward. Retain -`TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor` and -`TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt` as the two -representative cross-family workflows. - -### 2.4 Stage validation - -Run: - -```sh -go test ./internal/framework/contracts ./internal/framework/llm ./internal/framework/validate -go test ./internal/modules/generic/normalize/noop ./internal/modules/integration ./internal/modules/dnd/... -``` - -Stage 2 is complete when all named low-value tests are gone, retained owners -still pass, and no production file changed. - -## Stage 3 - Consolidate configuration, CLI state, and resolution ownership - -### 3.1 Version 3 configuration tests - -Before deleting `internal/core/config/v3_test.go`, preserve its only distinct -protections: - -- add a version-2 input to the strict file-decoding cases in - `file_config_contract_test.go`; require rejection and the migration category, - not the complete diagnostic; and -- ensure `env_contract_test.go` positively proves that empty configured cache - roots resolve to distinct `notarius/chunk-plans` and - `notarius/checkpoints` descendants of the supplied per-user cache root. - -Then delete `v3_test.go`. Do not duplicate its defaults, precedence, -redaction, invalid-source, or removed-field cases elsewhere. - -### 3.2 CLI state and maintained examples - -Move `emptyLookup` from `internal/cli/state_surfaces_test.go` to -`internal/cli/contract_test_helpers_test.go`, then delete -`state_surfaces_test.go` in full. Confirm its remaining behavior is still owned -by command/run contracts and `state_hardening_test.go`; do not transplant its -tests. - -In `internal/cli/production_contract_test.go`: - -- keep `TestDefaultCLICompositionResolvesMaintainedConfigurations`, but rename - it to `TestDefaultCLICompositionValidatesRepresentativeConfiguration` and - reduce it to one representative `config validate` command using empty/default - `Options`; this test owns fallback production composition, not - maintained-example enumeration; -- remove maintained-example resolution from - `TestProductionCatalogCoversMaintainedConfigurations` while retaining - required production registry members, typed codec/variant wiring, catalog - conversion, and the exact documented spell validator chain; and -- remove the maintained-example loop from - `TestProductionConfigValidationCoversModuleAndVariantFailures`, retaining one - valid baseline and each distinct failure mutation. - -Do not weaken `example_contract_test.go`; it remains the sole owner for loading, -resolving, listing, and executing the maintained examples. - -### 3.3 Default and Seriatim resolver duplication - -Delete `internal/framework/pipeline/default_modules_test.go` in full. - -Delete `internal/modules/seriatim/input/transcript/config_test.go` in full. -Delete `internal/modules/seriatim/input/transcript/testdata/pipeline.yml` with -it; the fixture is owned only by that deleted test file. Retain adapter parsing, -leaf registration, the Seriatim runner integration, generic resolver tests, -and production examples. - -### 3.4 D&D capability integration - -Rewrite `internal/modules/integration/dnd_spells_config_test.go` to own only two -cross-family capability failures: - -1. removing `source.transcript` from the Seriatim input spec must make the D&D - spell extractor incompatible; and -2. removing `dnd.spell_casts` from the spell extractor spec must make the - append-order merger incompatible. - -Use one compact table over a programmatically constructed profile and the -smallest catalog capable of resolution. Delete the successful-resolution, -stable-digest, and unknown-lane cases. Simplify or remove fixture/catalog -helpers that become unnecessary, but preserve helpers used by the retained -runner integration in sibling test files. - -### 3.5 Stage validation - -Run: - -```sh -go test ./internal/core/config ./internal/cli ./internal/framework/pipeline -go test ./internal/modules/seriatim/... ./internal/modules/integration -``` - -Stage 3 is complete when each behavior has the owner specified above, the -maintained examples are not redundantly enumerated, and all distinct migration, -default-composition, and capability protections remain. - -## Stage 4 - Rewrite brittle assertions at durable behavioral boundaries - -### 4.1 Source validation ownership and diagnostics - -In `internal/core/source/source_test.go`: - -- consolidate malformed `SourceRef` categories under the `ValidateRef` tests; -- reduce `TestValidateDocumentUnitReferences` to one case proving nested - reference failures receive unit/document context and one case proving the - document-only self-reference invariant; -- retain valid documents, required document/unit fields, duplicate IDs, - non-empty units, valid/reversed/missing references, unit lookup, and digest - sensitivity/determinism; and -- replace exact complete error equality with the minimum stable field/category - fragments needed to distinguish each failure. - -Do not add typed production errors during this pass. If stable fragments cannot -distinguish meaningful categories without a production change, retain the -narrowest current assertion and report that limitation rather than changing -production code. - -### 4.2 Private LLM response schemas - -In `internal/modules/dnd/chunk/scenes/schema_test.go`, replace the nested -`map[string]any` schema-structure walk with actual JSON Schema validation. -Add a small test helper that parses the instance and schema with -`jsonschema.UnmarshalJSON`, registers the schema with -`jsonschema.NewCompiler().AddResource`, compiles it, and calls -`schema.Validate`, matching the existing production validator boundary. Use a -representative valid scene response and mutations that reject: - -- obsolete segment-based boundary fields; -- non-positive start/end unit IDs; -- invalid `primary_mode` and `boundary_confidence` values; -- empty boundary caveats; and -- unknown properties. - -Retain identity/hash validity, DTO integer decoding, and mutation safety. - -In `internal/modules/dnd/extract/spells/schema_test.go`, validate one legal -private LLM response whose references omit `source_id`, and prove that adding a -canonical `source_id` is rejected. Retain response identity/hash, mutation -safety, and diagnostics non-leakage. Do not conflate this private transport -schema with the durable codec schema or alter the maintained durable fixture. - -### 4.3 Framework schema enumeration - -In `internal/framework/llm/schema_registry_test.go`, remove only the assertion -that `RegisteredResponseSchemas` has exactly two entries. Continue to assert: - -- returned keys are sorted; -- both required framework test schemas are present and valid; -- returned bytes are mutation-safe; -- diagnostics omit raw schema content; and -- D&D schemas are not registered in the domain-neutral framework registry. - -### 4.4 Stage validation - -Run: - -```sh -go test ./internal/core/source ./internal/framework/llm -go test ./internal/modules/dnd/chunk/scenes ./internal/modules/dnd/extract/spells ./internal/modules/dnd/codec/spells -``` - -Stage 4 is complete when failures express behavioral categories, private -schemas are validated by accepted/rejected instances, and durable schema -compatibility coverage remains unchanged. - -## Stage 5 - Make composition and architecture checks extension-friendly - -### 5.1 Family registrars - -Rewrite the success assertions in: - -- `internal/modules/generic/register/register_test.go`; -- `internal/modules/seriatim/register/register_test.go`; and -- `internal/modules/dnd/register/register_test.go`. - -Use required-membership helpers rather than exact equality for registered keys -and asset names. Prove representative family-owned entries are retrievable or -buildable through their registry boundary. Preserve: - -- validation of all required registry/asset dependencies before any mutation; -- contextual failure on duplicate family registration; -- absence of cross-family composition where that is an explicit architectural - ownership rule; and -- exact D&D spell default-validator order, because it is documented production - policy. - -Do not delete leaf spec/constructor/registration tests and do not move the CLI -production catalog into family tests. - -### 5.2 Central import-boundary enforcement - -Move the two rules from -`internal/framework/chunkplan/import_boundaries_test.go` into -`internal/modules/import_boundaries_test.go`: - -- production files under `internal/core/source` may import neither - `internal/framework` nor `internal/modules`; and -- production files under `internal/framework/chunkplan` may not import - `internal/modules`. - -Extend the centralized checker so its repository walk enforces those rules. -Add rule-level synthetic cases for both allowed and forbidden imports, using -the existing table/checker style. Retain the existing rejected fixture that -proves generic-to-concrete enforcement. Once both unique rules and checker -activation are protected centrally, delete -`internal/framework/chunkplan/import_boundaries_test.go`. - -### 5.3 Internal documentation routing - -Update only the `Tests To Inspect` routing needed to match the final suite in: - -- `docs/internal/state.md`; -- `docs/internal/pipeline.md`; -- `docs/internal/modules.md`; and -- `docs/internal/llm.md`. - -Replace deleted or nonexistent names such as -`internal/cli/state_surfaces_test.go` and `internal/cli/run_test.go` with concise -links or paths to the retained command/run contracts, state-hardening, -production-composition, maintained-example, checkpoint filesystem, and -cross-family integration owners. Do not create an exhaustive test inventory or -repeat subsystem contracts owned elsewhere. - -### 5.4 Stage validation - -Run: - -```sh -go test ./internal/modules/... ./internal/framework/chunkplan -go test ./internal/cli ./internal/framework/pipeline ./internal/framework/llm -``` - -Stage 5 is complete when legitimate family additions no longer require closed -inventory edits, all architectural rules remain executable in one checker, and -internal documentation names only existing retained owners. - -## Stage 6 - Repository acceptance and handoff - -1. Review the final diff against every required finding and retained-owner - statement in [Test Suite Policy Review](tests.md). Confirm that no production - `.go` file changed. -2. Run formatting on changed Go test files, then run: +4. If the baseline fails for an unrelated reason, stop and report it. Do not + modify unrelated behavior or tests to obtain a clean baseline. + +Stage 0 is complete when the baseline and preserved worktree scope are known. + +## Stage 1 — Inject materialized references during preparation + +1. Add `References contracts.ReferenceSet` to `pipeline.BuildRequest` and clone + it in `cloneBuildRequest`. +2. Refactor `pipeline.Prepare`, `prepareLane`, and `prepareValidatorChain` so + each builder receives the correct materialized reference target described in + the fixed decisions. Do not change operation-request delivery. +3. Update builder/preparation tests to prove: + - construction receives the correct target-specific reference content; + - extractor and extract validators receive independent clones of the same + logical set; + - mutation by one builder cannot affect another builder, the resolved + pipeline, or runtime requests; and + - empty/unmaterialized reference targets remain valid for callers that use + `Prepare` directly. +4. Update [Pipeline Internals](../internal/pipeline.md) and + [Module Internals](../internal/modules.md) to describe construction-time + reference delivery as current behavior once the code lands. +5. Run focused framework tests plus all repository-wide validation commands. + +Do not change configuration syntax, reference multiplicity, reference +materialization, manifests, or checkpoint formats in this stage. + +Stage 1 is complete when all stage builders receive isolated target references +at preparation and existing pipelines behave unchanged. + +## Stage 2 — Implement overlay parsing and effective catalog composition + +1. Add package-owned overlay DTOs, strict decoding, semantic validation, and + immutable effective-catalog composition under + `internal/modules/dnd/spells/catalog`. Keep overlay DTOs private; expose only + the minimal effective-catalog API needed by extraction and validation. +2. Preserve `LoadSRD5E2014`, existing SRD metadata, and the existing `Spell` + API. Do not represent overlay-only entries as incomplete SRD `Spell` values. +3. Add a helper that resolves the optional `spell_catalog` slot from a cloned + `ReferenceSet`, enforces zero-or-one JSON item, and returns the base-only or + base-plus-overlay effective catalog. Keep all filesystem access in framework + reference materialization. +4. Add focused behavioral tests for: + - base-only canonical names, normalized lookup, and immutability; + - a new overlay spell and an alias accepted only by lookup; + - augmentation of an existing canonical spell; + - deterministic results and semantic digest under reordered JSON catalogs, + spells, aliases, and object keys; + - duplicate IDs, wrong ruleset/schema version, empty required values, + unknown fields, trailing JSON, and empty bundles; + - same-key display conflicts and every cross-spell canonical/alias collision + category; and + - rejection of multiple items or a non-JSON item in the reference slot. +5. Add the external overlay format contract under `docs/integrations/` and + update the internal catalog inventory only after the behavior exists. +6. Run the catalog package tests, import-boundary tests, and repository-wide + validation commands. + +Prefer table-driven coverage for meaningful invalid-input categories, but do +not duplicate every parser case across higher layers. + +Stage 2 is complete when one immutable effective catalog deterministically +represents the embedded base plus a valid overlay bundle and rejects ambiguous +composition before execution. + +## Stage 3 — Ground the spell extractor + +1. Declare the optional `spell_catalog` reference slot on both the extractor + runtime and `ModuleSpec`, accepting only `application/json`, `Multiple: + false`, and `MaxBytes: 1048576`. Keep the existing campaign-reference slots + unchanged. +2. Update the extractor builder and constructor to resolve and retain the + effective catalog at preparation. A malformed overlay must make `Prepare` + fail before raw input is read or the LLM is called. +3. Generate the canonical-name-only JSON prompt material from the retained + catalog and add it to every structured completion request. Extend the + Scriptorium prompt declaration and package-owned prompt assets accordingly. +4. Update prompt wording to distinguish the allowed spell-name catalog from + campaign references and source evidence. Leave both response schemas + unchanged. +5. Add effective catalog identity to extractor manifest metadata without + exposing names, aliases, or raw overlay content. Include the new prompt + fragment in prompt hashing. +6. Update focused tests to protect: + - base-only requests contain all and only sorted canonical names; + - aliases and non-name metadata do not appear in prompt input; + - overlay canonical names do appear; + - prompt/schema preparation succeeds offline with the required input; + - prompt and manifest diagnostics omit source, reference, overlay, alias, + and catalog-name content; and + - malformed overlays fail construction without invoking the fake LLM. +7. Update the configuration reference-slot catalog, internal module docs, LLM + internals where needed, and the external overlay contract in the same stage. +8. Add a safe maintained overlay example file and bind it from the production + D&D example. Keep the minimal example base-only. Ensure example tests load + and materialize the referenced file, rather than checking YAML syntax alone. +9. Run focused extractor, prompt-asset, CLI example, and integration tests plus + all repository-wide validation commands. + +Stage 3 is complete when every spell extraction is grounded by the immutable +effective catalog and all overlay failures occur before paid work. + +## Stage 4 — Add catalog validation and production policy + +1. Implement and register `extract/dnd/spells/catalog` as specified above, + reusing the same catalog resolver as the extractor. +2. Insert it immediately after the shape validator in + `internal/modules/dnd/register`. Do not reorder or otherwise change the + remaining default validators. +3. Add package-level tests for canonical names, lookup normalization, aliases, + overlay spells, multiple unknown casts, the 20-issue message bound, shape + deferral, immutability, strict empty options, execution class, and + registration. +4. Update production composition tests to protect registration, exact default + chain placement, and construction from the same materialized overlay. +5. Add one assembled offline retry test using the production D&D registration + and a fake structured LLM, with two subcases: one remains unknown through + exhaustion, and one becomes catalog-valid on a retry. Configure a test-local + retry count and assert calls are bounded by `retries + 1`, exhausted + rejection is nonfatal, no rejected attempt advances to merge, and only the + accepted attempt contributes warnings and output. Do not repeat generic + retry cases already owned by framework tests. +6. Update the implemented-validator catalog in Configuration and the D&D + validator inventory in Module Internals. +7. Run focused validator, registrar, integration, and CLI tests plus all + repository-wide validation commands. + +Stage 4 is complete when no artifact containing a name outside the effective +catalog can pass the production spell extraction chain. + +## Stage 5 — Verify provenance, identity, and assembled behavior + +1. Add or extend the narrowest existing CLI/checkpoint contract tests to prove + that changing overlay file bytes changes checkpoint identity through + reference provenance, while semantically reordered overlay content retains + the same extractor-reported effective catalog digest. Do not alter + checkpoint schemas or path formats. +2. Verify that changing the configured overlay binding changes resolved + pipeline identity, and that manifest reference provenance records the + overlay origin, media type, size, and raw digest without content. +3. Exercise a representative production-composed run with an overlay-only + canonical spell and confirm prompt grounding, validation acceptance, output, + manifest catalog metadata, and reference provenance agree. +4. Verify base-only configuration remains valid and produces no overlay + provenance or overlay IDs. +5. Run: ```sh go test ./... go vet ./... go build ./cmd/notarius - go test -race ./internal/framework/pipeline ./internal/framework/llm ./internal/framework/checkpoint ./internal/cli ./internal/modules/integration - go test ./... -shuffle=on -count=5 + go test -race ./internal/framework/pipeline ./internal/cli ./internal/modules/dnd/extract/spells ./internal/modules/dnd/validate/spells/catalog ./internal/modules/integration ``` -3. Generate one coverage report for investigation. Confirm the new checkpoint - tests execute source, extract, merge, normalize, manifest-validation, and - corruption paths. Do not compare the percentage to the review baseline and - do not add tests merely to increase it. -4. Check documentation links and `git diff --check`. Confirm no fixtures were - updated automatically and no secrets or external-service requirements were - introduced. +Stage 5 is complete when the assembled offline workflow protects catalog +grounding, validation, provenance, retry count, and checkpoint invalidation at +their appropriate test boundaries. -Implementation is complete when: +## Stage 6 — Documentation, evaluation handoff, and completion -- every checkpoint stage has a real filesystem round trip and corrupted or - incompatible state is never silently reused; -- every deletion or rewrite in the feature roadmap is complete while its named - retained owner still passes; -- maintained examples have one example-contract owner plus one narrow default - production-composition smoke path; -- registrar and architecture checks permit legitimate extension without - weakening documented ownership or validator order; -- the full suite is deterministic, offline, credential-free, race-clean, and - diagnostically useful; -- internal documentation points to existing test owners; and -- all validation commands pass with no production behavior change. +1. Review every affected current-behavior owner and remove roadmap-style + wording from implemented contracts: + - `docs/config.md` owns the slot name, media type, byte limit, binding form, + validator key, and default chain; + - the new `docs/integrations/` document owns overlay JSON shape and + compatibility rules; + - `docs/internal/pipeline.md`, `docs/internal/modules.md`, and + `docs/internal/overview.md` own construction and component behavior; + - maintained copyable files remain under `examples/`; and + - operations documentation changes only if runtime handling actually + changes. +2. Check all links and ensure examples contain no credentials, private paths, + transcripts, or copyrighted spell descriptions. +3. If an approved LLM profile and human-reviewed transcript corpus are + available, run the base-only and overlay-capable baseline with + `retries: 2`. Record anonymized aggregate false positives, false negatives, + unknown-name rejections, acceptance by attempt, and model-call cost in the + feature roadmap without committing sensitive transcripts or raw prompts. +4. If those external evaluation inputs are unavailable, mark evaluation as + pending external execution and provide the operator with the exact config + and command used by the maintained example. Do not fabricate quality + results, invoke a paid model without authorization, or make live evaluation + part of the default Go test suite. +5. Update [D&D Spell Extraction Quality](dnd-spell-extraction.md) status to + distinguish completed implementation from pending or completed evaluation. + Remove completed baseline items from [Future Work](future.md), retaining a + concise link for deferred repair-aware retry and LLM-validator work. +6. Run `git diff --check` and the full Stage 5 validation set once more after + documentation and example changes. + +Stage 6 is complete when current behavior is documented in its canonical +owners, the feature roadmap accurately records implementation/evaluation +status, deferred work remains unimplemented, and all required checks pass. + +## Explicit Non-Goals + +- Changing the framework-wide retry default or retry orchestration. +- Supplying rejected candidates or validation feedback to retry attempts. +- Structured validation-issue contracts or retryability classification. +- An LLM-backed spell validator. +- Spell-name mutation or catalog-aware normalization. +- Multiple reference bindings per generic slot or list-valued reference + configuration. +- Spell descriptions, mechanics, unknown overlay levels, or class metadata. +- A configuration-version bump, environment override, checkpoint migration, + or checkpoint-format change. ## Open Questions -None. The feature roadmap contains enough evidence and policy decisions to -implement every stage without additional testing-policy choices. If a new test -reveals a production defect, that is a scope boundary rather than an open -planning question: stop the affected stage and request explicit authorization -before changing production behavior. +None. The overlay transport, file contract, composition policy, preparation +boundary, prompt content, validator behavior, retry baseline, provenance, and +test ownership are fixed by this plan. diff --git a/examples/dnd-spells-production.config.yml b/examples/dnd-spells-production.config.yml index d6a0ed9..f99498c 100644 --- a/examples/dnd-spells-production.config.yml +++ b/examples/dnd-spells-production.config.yml @@ -26,4 +26,6 @@ pipelines: max_units: 50 artifacts: spells: - extract: dnd/spells + extract: + module: dnd/spells + retries: 2 diff --git a/internal/modules/dnd/spells/catalog/SOURCES.md b/internal/modules/dnd/spells/catalog/SOURCES.md new file mode 100644 index 0000000..ba8045d --- /dev/null +++ b/internal/modules/dnd/spells/catalog/SOURCES.md @@ -0,0 +1,25 @@ +# D&D 5e 2014 SRD spell catalog sources + +The canonical catalog in `assets/dnd_5e_2014_srd_spells.json` was consolidated +from the eight class spell lists in the System Reference Document 5.1. Repeated +class-list entries were combined into one spell record while retaining spell +level and class membership. The consolidation produced 319 unique spells and +779 class memberships. + +Source: [System Reference Document 5.1](https://dnd.wizards.com/resources/systems-reference-document) + +License: [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/) + +Required attribution: + +> This work includes material taken from the System Reference Document 5.1 +> (“SRD 5.1”) by Wizards of the Coast LLC and available at +> https://dnd.wizards.com/resources/systems-reference-document. The SRD 5.1 is +> licensed under the Creative Commons Attribution 4.0 International License +> available at https://creativecommons.org/licenses/by/4.0/legalcode. + +The catalog contains names, spell levels, and class-list membership only. It +does not reproduce spell descriptions. Canonical spell names retain the +punctuation used by the source lists. The package lookup normalizes common +apostrophe variants, case, and repeated whitespace without changing those +display names. diff --git a/internal/modules/dnd/spells/catalog/assets/dnd_5e_2014_srd_spells.json b/internal/modules/dnd/spells/catalog/assets/dnd_5e_2014_srd_spells.json new file mode 100644 index 0000000..7ab9bbe --- /dev/null +++ b/internal/modules/dnd/spells/catalog/assets/dnd_5e_2014_srd_spells.json @@ -0,0 +1,331 @@ +{ + "id": "dnd-5e-2014-srd-spells", + "ruleset": "dnd-5e-2014", + "source": { + "title": "System Reference Document 5.1", + "version": "5.1", + "url": "https://dnd.wizards.com/resources/systems-reference-document", + "license": "CC-BY-4.0" + }, + "spells": [ + {"name":"Acid Arrow","level":2,"classes":["wizard"],"aliases":[]}, + {"name":"Acid Splash","level":0,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Aid","level":2,"classes":["cleric","paladin"],"aliases":[]}, + {"name":"Alarm","level":1,"classes":["ranger","wizard"],"aliases":[]}, + {"name":"Alter Self","level":2,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Animal Friendship","level":1,"classes":["bard","druid","ranger"],"aliases":[]}, + {"name":"Animal Messenger","level":2,"classes":["bard","druid","ranger"],"aliases":[]}, + {"name":"Animal Shapes","level":8,"classes":["druid"],"aliases":[]}, + {"name":"Animate Dead","level":3,"classes":["cleric","wizard"],"aliases":[]}, + {"name":"Animate Objects","level":5,"classes":["bard","sorcerer","wizard"],"aliases":[]}, + {"name":"Antilife Shell","level":5,"classes":["druid"],"aliases":[]}, + {"name":"Antimagic Field","level":8,"classes":["cleric","wizard"],"aliases":[]}, + {"name":"Antipathy/Sympathy","level":8,"classes":["druid","wizard"],"aliases":[]}, + {"name":"Arcane Eye","level":4,"classes":["wizard"],"aliases":[]}, + {"name":"Arcane Hand","level":5,"classes":["wizard"],"aliases":[]}, + {"name":"Arcane Lock","level":2,"classes":["wizard"],"aliases":[]}, + {"name":"Arcane Sword","level":7,"classes":["bard","wizard"],"aliases":[]}, + {"name":"Arcanist’s Magic Aura","level":2,"classes":["wizard"],"aliases":[]}, + {"name":"Astral Projection","level":9,"classes":["cleric","warlock","wizard"],"aliases":[]}, + {"name":"Augury","level":2,"classes":["cleric"],"aliases":[]}, + {"name":"Awaken","level":5,"classes":["bard","druid"],"aliases":[]}, + {"name":"Bane","level":1,"classes":["bard","cleric"],"aliases":[]}, + {"name":"Banishment","level":4,"classes":["cleric","paladin","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Barkskin","level":2,"classes":["druid","ranger"],"aliases":[]}, + {"name":"Beacon of Hope","level":3,"classes":["cleric"],"aliases":[]}, + {"name":"Bestow Curse","level":3,"classes":["bard","cleric","wizard"],"aliases":[]}, + {"name":"Black Tentacles","level":4,"classes":["wizard"],"aliases":[]}, + {"name":"Blade Barrier","level":6,"classes":["cleric"],"aliases":[]}, + {"name":"Bless","level":1,"classes":["cleric","paladin"],"aliases":[]}, + {"name":"Blight","level":4,"classes":["druid","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Blindness/Deafness","level":2,"classes":["bard","cleric","sorcerer","wizard"],"aliases":[]}, + {"name":"Blink","level":3,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Blur","level":2,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Branding Smite","level":2,"classes":["paladin"],"aliases":[]}, + {"name":"Burning Hands","level":1,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Call Lightning","level":3,"classes":["druid"],"aliases":[]}, + {"name":"Calm Emotions","level":2,"classes":["bard","cleric"],"aliases":[]}, + {"name":"Chain Lightning","level":6,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Charm Person","level":1,"classes":["bard","druid","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Chill Touch","level":0,"classes":["sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Circle of Death","level":6,"classes":["sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Clairvoyance","level":3,"classes":["bard","cleric","sorcerer","wizard"],"aliases":[]}, + {"name":"Clone","level":8,"classes":["wizard"],"aliases":[]}, + {"name":"Cloudkill","level":5,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Color Spray","level":1,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Command","level":1,"classes":["cleric","paladin"],"aliases":[]}, + {"name":"Commune","level":5,"classes":["cleric"],"aliases":[]}, + {"name":"Commune with Nature","level":5,"classes":["druid","ranger"],"aliases":[]}, + {"name":"Comprehend Languages","level":1,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Compulsion","level":4,"classes":["bard"],"aliases":[]}, + {"name":"Cone of Cold","level":5,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Confusion","level":4,"classes":["bard","druid","sorcerer","wizard"],"aliases":[]}, + {"name":"Conjure Animals","level":3,"classes":["druid","ranger"],"aliases":[]}, + {"name":"Conjure Celestial","level":7,"classes":["cleric"],"aliases":[]}, + {"name":"Conjure Elemental","level":5,"classes":["druid","wizard"],"aliases":[]}, + {"name":"Conjure Fey","level":6,"classes":["druid","warlock"],"aliases":[]}, + {"name":"Conjure Minor Elementals","level":4,"classes":["druid","wizard"],"aliases":[]}, + {"name":"Conjure Woodland Beings","level":4,"classes":["druid","ranger"],"aliases":[]}, + {"name":"Contact Other Plane","level":5,"classes":["warlock","wizard"],"aliases":[]}, + {"name":"Contagion","level":5,"classes":["cleric","druid"],"aliases":[]}, + {"name":"Contingency","level":6,"classes":["wizard"],"aliases":[]}, + {"name":"Continual Flame","level":2,"classes":["cleric","wizard"],"aliases":[]}, + {"name":"Control Water","level":4,"classes":["cleric","druid","wizard"],"aliases":[]}, + {"name":"Control Weather","level":8,"classes":["cleric","druid","wizard"],"aliases":[]}, + {"name":"Counterspell","level":3,"classes":["sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Create Food and Water","level":3,"classes":["cleric","paladin"],"aliases":[]}, + {"name":"Create Undead","level":6,"classes":["cleric","warlock","wizard"],"aliases":[]}, + {"name":"Create or Destroy Water","level":1,"classes":["cleric","druid"],"aliases":[]}, + {"name":"Creation","level":5,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Cure Wounds","level":1,"classes":["bard","cleric","druid","paladin","ranger"],"aliases":[]}, + {"name":"Dancing Lights","level":0,"classes":["bard","sorcerer","wizard"],"aliases":[]}, + {"name":"Darkness","level":2,"classes":["sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Darkvision","level":2,"classes":["druid","ranger","sorcerer","wizard"],"aliases":[]}, + {"name":"Daylight","level":3,"classes":["cleric","druid","paladin","ranger","sorcerer"],"aliases":[]}, + {"name":"Death Ward","level":4,"classes":["cleric","paladin"],"aliases":[]}, + {"name":"Delayed Blast Fireball","level":7,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Demiplane","level":8,"classes":["warlock","wizard"],"aliases":[]}, + {"name":"Detect Evil and Good","level":1,"classes":["cleric","paladin"],"aliases":[]}, + {"name":"Detect Magic","level":1,"classes":["bard","cleric","druid","paladin","ranger","sorcerer","wizard"],"aliases":[]}, + {"name":"Detect Poison and Disease","level":1,"classes":["cleric","druid","paladin","ranger"],"aliases":[]}, + {"name":"Detect Thoughts","level":2,"classes":["bard","sorcerer","wizard"],"aliases":[]}, + {"name":"Dimension Door","level":4,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Disguise Self","level":1,"classes":["bard","sorcerer","wizard"],"aliases":[]}, + {"name":"Disintegrate","level":6,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Dispel Evil and Good","level":5,"classes":["cleric","paladin"],"aliases":[]}, + {"name":"Dispel Magic","level":3,"classes":["bard","cleric","druid","paladin","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Divination","level":4,"classes":["cleric"],"aliases":[]}, + {"name":"Divine Favor","level":1,"classes":["paladin"],"aliases":[]}, + {"name":"Divine Word","level":7,"classes":["cleric"],"aliases":[]}, + {"name":"Dominate Beast","level":4,"classes":["druid","sorcerer"],"aliases":[]}, + {"name":"Dominate Monster","level":8,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Dominate Person","level":5,"classes":["bard","sorcerer","wizard"],"aliases":[]}, + {"name":"Dream","level":5,"classes":["bard","warlock","wizard"],"aliases":[]}, + {"name":"Druidcraft","level":0,"classes":["druid"],"aliases":[]}, + {"name":"Earthquake","level":8,"classes":["cleric","druid","sorcerer"],"aliases":[]}, + {"name":"Eldritch Blast","level":0,"classes":["warlock"],"aliases":[]}, + {"name":"Enhance Ability","level":2,"classes":["bard","cleric","druid","sorcerer"],"aliases":[]}, + {"name":"Enlarge/Reduce","level":2,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Entangle","level":1,"classes":["druid"],"aliases":[]}, + {"name":"Enthrall","level":2,"classes":["bard","warlock"],"aliases":[]}, + {"name":"Etherealness","level":7,"classes":["bard","cleric","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Expeditious Retreat","level":1,"classes":["sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Eyebite","level":6,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Fabricate","level":4,"classes":["wizard"],"aliases":[]}, + {"name":"Faerie Fire","level":1,"classes":["bard","druid"],"aliases":[]}, + {"name":"Faithful Hound","level":4,"classes":["wizard"],"aliases":[]}, + {"name":"False Life","level":1,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Fear","level":3,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Feather Fall","level":1,"classes":["bard","sorcerer","wizard"],"aliases":[]}, + {"name":"Feeblemind","level":8,"classes":["bard","druid","warlock","wizard"],"aliases":[]}, + {"name":"Find Familiar","level":1,"classes":["wizard"],"aliases":[]}, + {"name":"Find Steed","level":2,"classes":["paladin"],"aliases":[]}, + {"name":"Find Traps","level":2,"classes":["cleric","druid","ranger"],"aliases":[]}, + {"name":"Find the Path","level":6,"classes":["bard","cleric","druid"],"aliases":[]}, + {"name":"Finger of Death","level":7,"classes":["sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Fire Bolt","level":0,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Fire Shield","level":4,"classes":["wizard"],"aliases":[]}, + {"name":"Fire Storm","level":7,"classes":["cleric","druid","sorcerer"],"aliases":[]}, + {"name":"Fireball","level":3,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Flame Blade","level":2,"classes":["druid"],"aliases":[]}, + {"name":"Flame Strike","level":5,"classes":["cleric"],"aliases":[]}, + {"name":"Flaming Sphere","level":2,"classes":["druid","wizard"],"aliases":[]}, + {"name":"Flesh to Stone","level":6,"classes":["warlock","wizard"],"aliases":[]}, + {"name":"Floating Disk","level":1,"classes":["wizard"],"aliases":[]}, + {"name":"Fly","level":3,"classes":["sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Fog Cloud","level":1,"classes":["druid","ranger","sorcerer","wizard"],"aliases":[]}, + {"name":"Forbiddance","level":6,"classes":["cleric"],"aliases":[]}, + {"name":"Forcecage","level":7,"classes":["bard","warlock","wizard"],"aliases":[]}, + {"name":"Foresight","level":9,"classes":["bard","druid","warlock","wizard"],"aliases":[]}, + {"name":"Freedom of Movement","level":4,"classes":["bard","cleric","druid","ranger"],"aliases":[]}, + {"name":"Freezing Sphere","level":6,"classes":["wizard"],"aliases":[]}, + {"name":"Gaseous Form","level":3,"classes":["sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Gate","level":9,"classes":["cleric","sorcerer","wizard"],"aliases":[]}, + {"name":"Geas","level":5,"classes":["bard","cleric","druid","paladin","wizard"],"aliases":[]}, + {"name":"Gentle Repose","level":2,"classes":["cleric","wizard"],"aliases":[]}, + {"name":"Giant Insect","level":4,"classes":["druid"],"aliases":[]}, + {"name":"Glibness","level":8,"classes":["bard","warlock"],"aliases":[]}, + {"name":"Globe of Invulnerability","level":6,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Glyph of Warding","level":3,"classes":["bard","cleric","wizard"],"aliases":[]}, + {"name":"Goodberry","level":1,"classes":["druid","ranger"],"aliases":[]}, + {"name":"Grease","level":1,"classes":["wizard"],"aliases":[]}, + {"name":"Greater Invisibility","level":4,"classes":["bard","sorcerer","wizard"],"aliases":[]}, + {"name":"Greater Restoration","level":5,"classes":["bard","cleric","druid"],"aliases":[]}, + {"name":"Guardian of Faith","level":4,"classes":["cleric"],"aliases":[]}, + {"name":"Guards and Wards","level":6,"classes":["bard","wizard"],"aliases":[]}, + {"name":"Guidance","level":0,"classes":["cleric","druid"],"aliases":[]}, + {"name":"Guiding Bolt","level":1,"classes":["cleric"],"aliases":[]}, + {"name":"Gust of Wind","level":2,"classes":["druid","sorcerer","wizard"],"aliases":[]}, + {"name":"Hallow","level":5,"classes":["cleric"],"aliases":[]}, + {"name":"Hallucinatory Terrain","level":4,"classes":["bard","druid","warlock","wizard"],"aliases":[]}, + {"name":"Harm","level":6,"classes":["cleric"],"aliases":[]}, + {"name":"Haste","level":3,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Heal","level":6,"classes":["cleric","druid"],"aliases":[]}, + {"name":"Healing Word","level":1,"classes":["bard","cleric","druid"],"aliases":[]}, + {"name":"Heat Metal","level":2,"classes":["bard","druid"],"aliases":[]}, + {"name":"Hellish Rebuke","level":1,"classes":["warlock"],"aliases":[]}, + {"name":"Heroes’ Feast","level":6,"classes":["cleric","druid"],"aliases":[]}, + {"name":"Heroism","level":1,"classes":["bard","paladin"],"aliases":[]}, + {"name":"Hideous Laughter","level":1,"classes":["bard","wizard"],"aliases":[]}, + {"name":"Hold Monster","level":5,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Hold Person","level":2,"classes":["bard","cleric","druid","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Holy Aura","level":8,"classes":["cleric"],"aliases":[]}, + {"name":"Hunter’s Mark","level":1,"classes":["ranger"],"aliases":[]}, + {"name":"Hypnotic Pattern","level":3,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Ice Storm","level":4,"classes":["druid","sorcerer","wizard"],"aliases":[]}, + {"name":"Identify","level":1,"classes":["bard","wizard"],"aliases":[]}, + {"name":"Illusory Script","level":1,"classes":["bard","warlock","wizard"],"aliases":[]}, + {"name":"Imprisonment","level":9,"classes":["warlock","wizard"],"aliases":[]}, + {"name":"Incendiary Cloud","level":8,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Inflict Wounds","level":1,"classes":["cleric"],"aliases":[]}, + {"name":"Insect Plague","level":5,"classes":["cleric","druid","sorcerer"],"aliases":[]}, + {"name":"Instant Summons","level":6,"classes":["wizard"],"aliases":[]}, + {"name":"Invisibility","level":2,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Irresistible Dance","level":6,"classes":["bard","wizard"],"aliases":[]}, + {"name":"Jump","level":1,"classes":["druid","ranger","sorcerer","wizard"],"aliases":[]}, + {"name":"Knock","level":2,"classes":["bard","sorcerer","wizard"],"aliases":[]}, + {"name":"Legend Lore","level":5,"classes":["bard","cleric","wizard"],"aliases":[]}, + {"name":"Lesser Restoration","level":2,"classes":["bard","cleric","druid","paladin","ranger"],"aliases":[]}, + {"name":"Levitate","level":2,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Light","level":0,"classes":["bard","cleric","sorcerer","wizard"],"aliases":[]}, + {"name":"Lightning Bolt","level":3,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Locate Animals or Plants","level":2,"classes":["bard","druid","ranger"],"aliases":[]}, + {"name":"Locate Creature","level":4,"classes":["bard","cleric","druid","paladin","ranger","wizard"],"aliases":[]}, + {"name":"Locate Object","level":2,"classes":["bard","cleric","druid","paladin","ranger","wizard"],"aliases":[]}, + {"name":"Longstrider","level":1,"classes":["bard","druid","ranger","wizard"],"aliases":[]}, + {"name":"Mage Armor","level":1,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Mage Hand","level":0,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Magic Circle","level":3,"classes":["cleric","paladin","warlock","wizard"],"aliases":[]}, + {"name":"Magic Jar","level":6,"classes":["wizard"],"aliases":[]}, + {"name":"Magic Missile","level":1,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Magic Mouth","level":2,"classes":["bard","wizard"],"aliases":[]}, + {"name":"Magic Weapon","level":2,"classes":["paladin","wizard"],"aliases":[]}, + {"name":"Magnificent Mansion","level":7,"classes":["bard","wizard"],"aliases":[]}, + {"name":"Major Image","level":3,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Mass Cure Wounds","level":5,"classes":["bard","cleric","druid"],"aliases":[]}, + {"name":"Mass Heal","level":9,"classes":["cleric"],"aliases":[]}, + {"name":"Mass Healing Word","level":3,"classes":["cleric"],"aliases":[]}, + {"name":"Mass Suggestion","level":6,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Maze","level":8,"classes":["wizard"],"aliases":[]}, + {"name":"Meld into Stone","level":3,"classes":["cleric","druid"],"aliases":[]}, + {"name":"Mending","level":0,"classes":["bard","cleric","druid","sorcerer","wizard"],"aliases":[]}, + {"name":"Message","level":0,"classes":["bard","sorcerer","wizard"],"aliases":[]}, + {"name":"Meteor Swarm","level":9,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Mind Blank","level":8,"classes":["bard","wizard"],"aliases":[]}, + {"name":"Minor Illusion","level":0,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Mirage Arcane","level":7,"classes":["bard","druid","wizard"],"aliases":[]}, + {"name":"Mirror Image","level":2,"classes":["sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Mislead","level":5,"classes":["bard","wizard"],"aliases":[]}, + {"name":"Misty Step","level":2,"classes":["sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Modify Memory","level":5,"classes":["bard","wizard"],"aliases":[]}, + {"name":"Moonbeam","level":2,"classes":["druid"],"aliases":[]}, + {"name":"Move Earth","level":6,"classes":["druid","sorcerer","wizard"],"aliases":[]}, + {"name":"Nondetection","level":3,"classes":["bard","ranger","wizard"],"aliases":[]}, + {"name":"Pass without Trace","level":2,"classes":["druid","ranger"],"aliases":[]}, + {"name":"Passwall","level":5,"classes":["wizard"],"aliases":[]}, + {"name":"Phantasmal Killer","level":4,"classes":["wizard"],"aliases":[]}, + {"name":"Phantom Steed","level":3,"classes":["wizard"],"aliases":[]}, + {"name":"Planar Ally","level":6,"classes":["cleric"],"aliases":[]}, + {"name":"Planar Binding","level":5,"classes":["bard","cleric","druid","wizard"],"aliases":[]}, + {"name":"Plane Shift","level":7,"classes":["cleric","druid","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Plant Growth","level":3,"classes":["bard","druid","ranger"],"aliases":[]}, + {"name":"Poison Spray","level":0,"classes":["druid","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Polymorph","level":4,"classes":["bard","druid","sorcerer","wizard"],"aliases":[]}, + {"name":"Power Word Kill","level":9,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Power Word Stun","level":8,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Prayer of Healing","level":2,"classes":["cleric"],"aliases":[]}, + {"name":"Prestidigitation","level":0,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Prismatic Spray","level":7,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Prismatic Wall","level":9,"classes":["wizard"],"aliases":[]}, + {"name":"Private Sanctum","level":4,"classes":["wizard"],"aliases":[]}, + {"name":"Produce Flame","level":0,"classes":["druid"],"aliases":[]}, + {"name":"Programmed Illusion","level":6,"classes":["bard","wizard"],"aliases":[]}, + {"name":"Project Image","level":7,"classes":["bard","wizard"],"aliases":[]}, + {"name":"Protection from Energy","level":3,"classes":["cleric","druid","ranger","sorcerer","wizard"],"aliases":[]}, + {"name":"Protection from Evil and Good","level":1,"classes":["cleric","paladin","warlock","wizard"],"aliases":[]}, + {"name":"Protection from Poison","level":2,"classes":["cleric","druid","paladin","ranger"],"aliases":[]}, + {"name":"Purify Food and Drink","level":1,"classes":["cleric","druid","paladin"],"aliases":[]}, + {"name":"Raise Dead","level":5,"classes":["bard","cleric","paladin"],"aliases":[]}, + {"name":"Ray of Enfeeblement","level":2,"classes":["warlock","wizard"],"aliases":[]}, + {"name":"Ray of Frost","level":0,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Regenerate","level":7,"classes":["bard","cleric","druid"],"aliases":[]}, + {"name":"Reincarnate","level":5,"classes":["druid"],"aliases":[]}, + {"name":"Remove Curse","level":3,"classes":["cleric","paladin","warlock","wizard"],"aliases":[]}, + {"name":"Resilient Sphere","level":4,"classes":["wizard"],"aliases":[]}, + {"name":"Resistance","level":0,"classes":["cleric","druid"],"aliases":[]}, + {"name":"Resurrection","level":7,"classes":["bard","cleric"],"aliases":[]}, + {"name":"Reverse Gravity","level":7,"classes":["druid","sorcerer","wizard"],"aliases":[]}, + {"name":"Revivify","level":3,"classes":["cleric","paladin"],"aliases":[]}, + {"name":"Rope Trick","level":2,"classes":["wizard"],"aliases":[]}, + {"name":"Sacred Flame","level":0,"classes":["cleric"],"aliases":[]}, + {"name":"Sanctuary","level":1,"classes":["cleric"],"aliases":[]}, + {"name":"Scorching Ray","level":2,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Scrying","level":5,"classes":["bard","cleric","druid","warlock","wizard"],"aliases":[]}, + {"name":"Secret Chest","level":4,"classes":["wizard"],"aliases":[]}, + {"name":"See Invisibility","level":2,"classes":["bard","sorcerer","wizard"],"aliases":[]}, + {"name":"Seeming","level":5,"classes":["bard","sorcerer","wizard"],"aliases":[]}, + {"name":"Sending","level":3,"classes":["bard","cleric","wizard"],"aliases":[]}, + {"name":"Sequester","level":7,"classes":["wizard"],"aliases":[]}, + {"name":"Shapechange","level":9,"classes":["druid","wizard"],"aliases":[]}, + {"name":"Shatter","level":2,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Shield","level":1,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Shield of Faith","level":1,"classes":["cleric","paladin"],"aliases":[]}, + {"name":"Shillelagh","level":0,"classes":["druid"],"aliases":[]}, + {"name":"Shocking Grasp","level":0,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Silence","level":2,"classes":["bard","cleric","ranger"],"aliases":[]}, + {"name":"Silent Image","level":1,"classes":["bard","sorcerer","wizard"],"aliases":[]}, + {"name":"Simulacrum","level":7,"classes":["wizard"],"aliases":[]}, + {"name":"Sleep","level":1,"classes":["bard","sorcerer","wizard"],"aliases":[]}, + {"name":"Sleet Storm","level":3,"classes":["druid","sorcerer","wizard"],"aliases":[]}, + {"name":"Slow","level":3,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Spare the Dying","level":0,"classes":["cleric"],"aliases":[]}, + {"name":"Speak with Animals","level":1,"classes":["bard","druid","ranger"],"aliases":[]}, + {"name":"Speak with Dead","level":3,"classes":["bard","cleric"],"aliases":[]}, + {"name":"Speak with Plants","level":3,"classes":["bard","druid","ranger"],"aliases":[]}, + {"name":"Spider Climb","level":2,"classes":["sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Spike Growth","level":2,"classes":["druid","ranger"],"aliases":[]}, + {"name":"Spirit Guardians","level":3,"classes":["cleric"],"aliases":[]}, + {"name":"Spiritual Weapon","level":2,"classes":["cleric"],"aliases":[]}, + {"name":"Stinking Cloud","level":3,"classes":["bard","sorcerer","wizard"],"aliases":[]}, + {"name":"Stone Shape","level":4,"classes":["cleric","druid","wizard"],"aliases":[]}, + {"name":"Stoneskin","level":4,"classes":["druid","ranger","sorcerer","wizard"],"aliases":[]}, + {"name":"Storm of Vengeance","level":9,"classes":["druid"],"aliases":[]}, + {"name":"Suggestion","level":2,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Sunbeam","level":6,"classes":["druid","sorcerer","wizard"],"aliases":[]}, + {"name":"Sunburst","level":8,"classes":["druid","sorcerer","wizard"],"aliases":[]}, + {"name":"Symbol","level":7,"classes":["bard","cleric","wizard"],"aliases":[]}, + {"name":"Telekinesis","level":5,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Telepathic Bond","level":5,"classes":["wizard"],"aliases":[]}, + {"name":"Teleport","level":7,"classes":["bard","cleric","sorcerer","wizard"],"aliases":[]}, + {"name":"Teleportation Circle","level":5,"classes":["bard","sorcerer","wizard"],"aliases":[]}, + {"name":"Thaumaturgy","level":0,"classes":["cleric"],"aliases":[]}, + {"name":"Thunderwave","level":1,"classes":["bard","druid","sorcerer","wizard"],"aliases":[]}, + {"name":"Time Stop","level":9,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Tiny Hut","level":3,"classes":["bard","wizard"],"aliases":[]}, + {"name":"Tongues","level":3,"classes":["bard","cleric","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Transport via Plants","level":6,"classes":["druid"],"aliases":[]}, + {"name":"Tree Stride","level":5,"classes":["druid","ranger"],"aliases":[]}, + {"name":"True Polymorph","level":9,"classes":["bard","warlock","wizard"],"aliases":[]}, + {"name":"True Resurrection","level":9,"classes":["cleric","druid"],"aliases":[]}, + {"name":"True Seeing","level":6,"classes":["bard","cleric","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"True Strike","level":0,"classes":["bard","sorcerer","warlock","wizard"],"aliases":[]}, + {"name":"Unseen Servant","level":1,"classes":["bard","warlock","wizard"],"aliases":[]}, + {"name":"Vampiric Touch","level":3,"classes":["warlock","wizard"],"aliases":[]}, + {"name":"Vicious Mockery","level":0,"classes":["bard"],"aliases":[]}, + {"name":"Wall of Fire","level":4,"classes":["druid","sorcerer","wizard"],"aliases":[]}, + {"name":"Wall of Force","level":5,"classes":["wizard"],"aliases":[]}, + {"name":"Wall of Ice","level":6,"classes":["wizard"],"aliases":[]}, + {"name":"Wall of Stone","level":5,"classes":["druid","sorcerer","wizard"],"aliases":[]}, + {"name":"Wall of Thorns","level":6,"classes":["druid"],"aliases":[]}, + {"name":"Warding Bond","level":2,"classes":["cleric"],"aliases":[]}, + {"name":"Water Breathing","level":3,"classes":["druid","ranger","sorcerer","wizard"],"aliases":[]}, + {"name":"Water Walk","level":3,"classes":["cleric","druid","ranger","sorcerer"],"aliases":[]}, + {"name":"Web","level":2,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Weird","level":9,"classes":["wizard"],"aliases":[]}, + {"name":"Wind Walk","level":6,"classes":["druid"],"aliases":[]}, + {"name":"Wind Wall","level":3,"classes":["druid","ranger"],"aliases":[]}, + {"name":"Wish","level":9,"classes":["sorcerer","wizard"],"aliases":[]}, + {"name":"Word of Recall","level":6,"classes":["cleric"],"aliases":[]}, + {"name":"Zone of Truth","level":2,"classes":["bard","cleric","paladin"],"aliases":[]} + ] +} diff --git a/internal/modules/dnd/spells/catalog/catalog.go b/internal/modules/dnd/spells/catalog/catalog.go new file mode 100644 index 0000000..481e6a1 --- /dev/null +++ b/internal/modules/dnd/spells/catalog/catalog.go @@ -0,0 +1,214 @@ +// Package catalog provides immutable D&D spell reference catalogs. +package catalog + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "io" + "sort" + "strings" + "sync" +) + +const ( + SRD5E2014ID = "dnd-5e-2014-srd-spells" + SRD5E2014Ruleset = "dnd-5e-2014" +) + +//go:embed assets/dnd_5e_2014_srd_spells.json +var catalogAssets embed.FS + +type Source struct { + Title string `json:"title"` + Version string `json:"version"` + URL string `json:"url"` + License string `json:"license"` +} + +type Spell struct { + Name string `json:"name"` + Level int `json:"level"` + Classes []string `json:"classes"` + Aliases []string `json:"aliases"` +} + +type document struct { + ID string `json:"id"` + Ruleset string `json:"ruleset"` + Source Source `json:"source"` + Spells []Spell `json:"spells"` +} + +type Catalog struct { + id string + ruleset string + source Source + spells []Spell + lookup map[string]int +} + +var ( + loadSRD5E2014Once sync.Once + loadedSRD5E2014 Catalog + loadSRD5E2014Err error +) + +// LoadSRD5E2014 returns the embedded SRD 5.1 spell catalog. Returned values do +// not expose mutable catalog storage. +func LoadSRD5E2014() (Catalog, error) { + loadSRD5E2014Once.Do(func() { + loadedSRD5E2014, loadSRD5E2014Err = load("assets/dnd_5e_2014_srd_spells.json") + }) + return loadedSRD5E2014, loadSRD5E2014Err +} + +func (c Catalog) ID() string { return c.id } +func (c Catalog) Ruleset() string { return c.ruleset } +func (c Catalog) Source() Source { return c.source } + +func (c Catalog) Spells() []Spell { + return cloneSpells(c.spells) +} + +// Lookup matches canonical names and declared aliases after case, whitespace, +// and common apostrophe variants are normalized. +func (c Catalog) Lookup(name string) (Spell, bool) { + index, ok := c.lookup[lookupKey(name)] + if !ok { + return Spell{}, false + } + return cloneSpell(c.spells[index]), true +} + +func load(path string) (Catalog, error) { + raw, err := catalogAssets.ReadFile(path) + if err != nil { + return Catalog{}, fmt.Errorf("read spell catalog: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var doc document + if err := decoder.Decode(&doc); err != nil { + return Catalog{}, fmt.Errorf("decode spell catalog: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return Catalog{}, fmt.Errorf("decode spell catalog: multiple JSON values") + } + return newCatalog(doc) +} + +func newCatalog(doc document) (Catalog, error) { + if doc.ID != SRD5E2014ID { + return Catalog{}, fmt.Errorf("spell catalog id %q does not match %q", doc.ID, SRD5E2014ID) + } + if doc.Ruleset != SRD5E2014Ruleset { + return Catalog{}, fmt.Errorf("spell catalog ruleset %q does not match %q", doc.Ruleset, SRD5E2014Ruleset) + } + if strings.TrimSpace(doc.Source.Title) == "" || strings.TrimSpace(doc.Source.Version) == "" || + strings.TrimSpace(doc.Source.URL) == "" || strings.TrimSpace(doc.Source.License) == "" { + return Catalog{}, fmt.Errorf("spell catalog source metadata must be complete") + } + if len(doc.Spells) == 0 { + return Catalog{}, fmt.Errorf("spell catalog spells must not be empty") + } + + allowedClasses := map[string]struct{}{ + "bard": {}, "cleric": {}, "druid": {}, "paladin": {}, + "ranger": {}, "sorcerer": {}, "warlock": {}, "wizard": {}, + } + lookup := make(map[string]int, len(doc.Spells)) + for index := range doc.Spells { + spell := &doc.Spells[index] + if spell.Name != strings.TrimSpace(spell.Name) || spell.Name == "" { + return Catalog{}, fmt.Errorf("spell[%d] name must be non-empty and trimmed", index) + } + if index > 0 && doc.Spells[index-1].Name >= spell.Name { + return Catalog{}, fmt.Errorf("spell catalog names must be unique and sorted") + } + if spell.Level < 0 || spell.Level > 9 { + return Catalog{}, fmt.Errorf("spell %q level must be between 0 and 9", spell.Name) + } + if len(spell.Classes) == 0 { + return Catalog{}, fmt.Errorf("spell %q classes must not be empty", spell.Name) + } + if err := validateSortedStrings(spell.Classes, func(value string) bool { + _, ok := allowedClasses[value] + return ok + }); err != nil { + return Catalog{}, fmt.Errorf("spell %q classes: %w", spell.Name, err) + } + if spell.Aliases == nil { + return Catalog{}, fmt.Errorf("spell %q aliases must be present", spell.Name) + } + if err := validateSortedStrings(spell.Aliases, func(value string) bool { + return value == strings.TrimSpace(value) && value != "" + }); err != nil { + return Catalog{}, fmt.Errorf("spell %q aliases: %w", spell.Name, err) + } + for _, candidate := range append([]string{spell.Name}, spell.Aliases...) { + key := lookupKey(candidate) + if key == "" { + return Catalog{}, fmt.Errorf("spell %q has an empty lookup key", spell.Name) + } + if previous, exists := lookup[key]; exists { + return Catalog{}, fmt.Errorf("spell %q lookup key conflicts with %q", spell.Name, doc.Spells[previous].Name) + } + lookup[key] = index + } + } + + return Catalog{ + id: doc.ID, + ruleset: doc.Ruleset, + source: doc.Source, + spells: cloneSpells(doc.Spells), + lookup: lookup, + }, nil +} + +func validateSortedStrings(values []string, valid func(string) bool) error { + if !sort.StringsAreSorted(values) { + return fmt.Errorf("values must be sorted") + } + for index, value := range values { + if !valid(value) { + return fmt.Errorf("value %q is not supported", value) + } + if index > 0 && values[index-1] == value { + return fmt.Errorf("value %q is duplicated", value) + } + } + return nil +} + +var apostropheVariants = strings.NewReplacer( + "‘", "'", + "’", "'", + "ʼ", "'", + "'", "'", +) + +func lookupKey(value string) string { + value = apostropheVariants.Replace(value) + return strings.ToLower(strings.Join(strings.Fields(value), " ")) +} + +func cloneSpells(values []Spell) []Spell { + if values == nil { + return nil + } + out := make([]Spell, len(values)) + for index, value := range values { + out[index] = cloneSpell(value) + } + return out +} + +func cloneSpell(value Spell) Spell { + value.Classes = append([]string(nil), value.Classes...) + value.Aliases = append([]string(nil), value.Aliases...) + return value +} diff --git a/internal/modules/dnd/spells/catalog/catalog_test.go b/internal/modules/dnd/spells/catalog/catalog_test.go new file mode 100644 index 0000000..94ef805 --- /dev/null +++ b/internal/modules/dnd/spells/catalog/catalog_test.go @@ -0,0 +1,62 @@ +package catalog + +import ( + "reflect" + "testing" +) + +func TestLoadSRD5E2014Catalog(t *testing.T) { + catalog, err := LoadSRD5E2014() + if err != nil { + t.Fatalf("LoadSRD5E2014() error = %v", err) + } + if catalog.ID() != SRD5E2014ID || catalog.Ruleset() != SRD5E2014Ruleset { + t.Fatalf("catalog identity = %q/%q", catalog.ID(), catalog.Ruleset()) + } + if source := catalog.Source(); source.Version != "5.1" || source.License != "CC-BY-4.0" || source.URL == "" { + t.Fatalf("catalog source = %#v", source) + } + spells := catalog.Spells() + if len(spells) != 319 { + t.Fatalf("spell count = %d, want 319", len(spells)) + } + memberships := 0 + for _, spell := range spells { + memberships += len(spell.Classes) + } + if memberships != 779 { + t.Fatalf("class memberships = %d, want 779", memberships) + } + + cureWounds, ok := catalog.Lookup("cure wounds") + if !ok || cureWounds.Name != "Cure Wounds" || cureWounds.Level != 1 || + !reflect.DeepEqual(cureWounds.Classes, []string{"bard", "cleric", "druid", "paladin", "ranger"}) { + t.Fatalf("Cure Wounds lookup = %#v, present=%t", cureWounds, ok) + } + if huntersMark, ok := catalog.Lookup(" HUNTER'S MARK "); !ok || huntersMark.Name != "Hunter’s Mark" { + t.Fatalf("Hunter's Mark lookup = %#v, present=%t", huntersMark, ok) + } + if _, ok := catalog.Lookup("Definitely Not A Spell"); ok { + t.Fatal("unknown spell lookup succeeded") + } +} + +func TestCatalogResultsDoNotExposeMutableStorage(t *testing.T) { + catalog, err := LoadSRD5E2014() + if err != nil { + t.Fatal(err) + } + spells := catalog.Spells() + spells[0].Name = "changed" + spells[0].Classes[0] = "changed" + + acidArrow, ok := catalog.Lookup("Acid Arrow") + if !ok || acidArrow.Name != "Acid Arrow" || !reflect.DeepEqual(acidArrow.Classes, []string{"wizard"}) { + t.Fatalf("catalog mutated through Spells(): %#v, present=%t", acidArrow, ok) + } + acidArrow.Classes[0] = "changed" + again, ok := catalog.Lookup("Acid Arrow") + if !ok || !reflect.DeepEqual(again.Classes, []string{"wizard"}) { + t.Fatalf("catalog mutated through Lookup(): %#v, present=%t", again, ok) + } +}