Compare commits

..

16 Commits

Author SHA1 Message Date
d752c51aec Centralize D&D candidate JSON codecs 2026-07-25 13:05:02 +00:00
8199d95dc1 Reuse document indexes in D&D normalization 2026-07-25 13:01:31 +00:00
97cdb01357 Reuse document indexes in D&D validators 2026-07-25 12:57:31 +00:00
7a66095912 Reuse indexes for D&D citation validation 2026-07-25 12:53:48 +00:00
9d1356a20e Add D&D refactoring plan and isolate merger results 2026-07-25 12:51:36 +00:00
e4471fc300 Fix D&D extraction issues and retire the completed audit 2026-07-25 12:30:42 +00:00
84a2854b5e Close D&D extraction audit documentation 2026-07-24 14:48:13 +00:00
a1b76093ce Document D&D extractor contract 2026-07-24 14:46:19 +00:00
1aa30a73db Protect D&D citation prompt ordering 2026-07-24 14:44:10 +00:00
dc7c0e2f9e Move scene semantics to deterministic validation 2026-07-24 14:40:54 +00:00
e2cb0d901a Centralize D&D extraction request preparation 2026-07-24 14:38:42 +00:00
8e0b029f5f Order combat and NPC interaction extraction by document position 2026-07-24 14:33:34 +00:00
9bbf2535dd Order spell and NPC extraction by document position 2026-07-24 14:29:19 +00:00
83fde83a58 Preserve literal ordering for foreign source references 2026-07-24 14:25:02 +00:00
bef3d1359d Fingerprint D&D extractor mapping policies 2026-07-24 14:23:35 +00:00
1ff449435f Centralize D&D source reference ordering 2026-07-24 14:20:10 +00:00
77 changed files with 1803 additions and 2804 deletions

View File

@@ -53,7 +53,8 @@ LLM-backed extensions own their prompt definitions and response schemas under
package-local embedded assets. Shared filesystem composition belongs in
`internal/framework/promptfs`; reusable D&D prompt fragments, reference
declarations, prompt-input assembly, and source-unit/citation helpers belong in
`internal/modules/dnd/shared`, which also owns bounded D&D diagnostics. The
`internal/modules/dnd/shared`, which also owns document-aware source-reference
ordering and canonicalization plus bounded D&D diagnostics. The
D&D scene chunker and spell, NPC, combat-turn, NPC-interaction, and scene-description extractors use ordered
package-local prompt manifests for both rendering and prompt fingerprinting, so
only the shared fragments each prompt actually renders participate in either
@@ -64,12 +65,13 @@ lanes. The canonical ordering and cache-boundary policy is documented in
contracts expose only Notarius structured-completion types, not Scriptorium
public types.
The shared `ChunkPromptMaterial` helper owns common transcript material
preparation for the spell, NPC, combat-turn, and NPC-interaction extractors. It clones supplied
source metadata, falls back to the materialized chunk when content is absent,
checks that content remains chunk-identical, and fills only the common default
fields. Extractors retain their request validation and wrap helper errors with
their module context.
The shared `PrepareChunkExtraction` helper owns common extraction preflight and
transcript material preparation for the spell, NPC, combat-turn,
NPC-interaction, and scene-description extractors. It validates common request
state, clones supplied source metadata, falls back to the materialized chunk
when content is absent, checks that content remains chunk-identical, and fills
only the common default fields. Extractors retain receiver, dependency, and
lane-specific checks locally and wrap helper errors with their module context.
Reference material may inform a module or prompt but must not become source
evidence. The resolver and materializer behavior is described in
@@ -201,7 +203,7 @@ The spell extractor prepares a structured request from one chunk, the
chunk-scoped source input, the session, and optional D&D reference inputs. It
decodes the model response, assigns the generic source identity to every source
reference, canonicalizes duplicate references, orders spell casts by their
earliest cited unit, and returns `dnd.SpellList`.
earliest valid source-document position, and returns `dnd.SpellList`.
Its private response schema admits only the structural transport envelope:
required fields, JSON types, array and object shapes, and unknown-field
@@ -322,12 +324,13 @@ mapping, and the names-only registry projection
participate in checkpoint identity, while generated producer identity remains
framework provenance.
The domain-owned `internal/modules/dnd/npcinteractions` package defines
canonical source-reference and occurrence ordering, valid-evidence eligibility,
and collision-safe exact identity. The interaction normalizer and normalized
invariants validator both consume those rules, so their production and checking
paths cannot drift. Normalizer and relatedness warning lists use the shared D&D
diagnostic cap and emit a final omission-summary warning when truncated.
The shared D&D source-reference order defines canonical evidence ordering. The
domain-owned `internal/modules/dnd/npcinteractions` package defines occurrence
ordering, valid-evidence eligibility, and collision-safe exact identity. The
interaction normalizer and normalized invariants validator consume those
rules, so their production and checking paths cannot drift. Normalizer and
relatedness warning lists use the shared D&D diagnostic cap and emit a final
omission-summary warning when truncated.
### `internal/modules/dnd/normalize/npcs`
@@ -356,9 +359,9 @@ The typed spell normalizer resolves the optional `spell_catalog` reference into
the same immutable SRD-plus-overlay effective catalog used by spell extraction
and catalog validation. It performs no LLM calls. For each spell cast it
canonicalizes recognized names using the catalog's case, whitespace,
apostrophe, and alias rules; sorts source references by source identity and
unit boundaries; removes only exact reference duplicates; and emits bounded,
scoped warnings for each mutation or unresolved name.
apostrophe, and alias rules; canonicalizes source references with the shared
document-aware order; removes only exact reference duplicates; and emits
bounded, scoped warnings for each mutation or unresolved name.
After those per-cast changes, it collapses only casts with the same canonical
spell, case-folded and whitespace-normalized caster, and complete non-empty
@@ -575,6 +578,37 @@ When adding a production module or validator:
Do not add the extension to `docs/development.md`; that file routes by task and
does not inventory implementations.
### D&D Extractor Contract
New D&D extractors preserve these package-owned responsibilities:
- Reject unknown options unless an option namespace is intentionally
extensible, and use shared common preflight while retaining receiver,
dependency, and lane-specific checks locally.
- Return independently owned results and exposed metadata that callers may
safely mutate.
- Keep the private response DTO, structural response schema and its identity,
provider-response mapping, durable artifact conversion, and lane diagnostics
in the owning package.
- Include every stable semantic input that can change durable output in
checkpoint identity. Consider prompt, schema, mapping, canonicalization,
prepared reference projections, identity, normalization, and trimming where
applicable.
- Add focused behavioral coverage where the lane's risks warrant it, including
construction and registration, option rejection, preflight, provider
failures, structured decoding, mapping and ownership, prompt
role/input/cache order, and checkpoint invalidation.
Prompt ordering, shared-asset ownership, cache boundaries, and private-schema
rules are defined in [LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries).
[Pipeline Internals](pipeline.md#reference-materialization) owns reference
materialization, and its [checkpoint hooks](pipeline.md#checkpoint-and-debug-hooks)
define checkpoint behavior. Follow [Architecture](../policy/architecture.md#source-and-domain-boundaries)
for ownership boundaries and the [Testing Policy](../policy/testing.md) when
selecting durable coverage. This contract intentionally does not prescribe
prompt prose or length, hashes, test counts, filenames, fixture layouts, or
generic implementation builders.
## Tests To Inspect
- Package-local `*_test.go` files under the module or validator being changed.

View File

@@ -101,7 +101,7 @@ Configuration. The implemented module packages are:
| `internal/modules/dnd/extract/combatturns` | Maps private structured model output to source-grounded D&D combat-turn candidates and preserves chronology and invalid candidate values for validators. |
| `internal/modules/dnd/extract/npcinteractions` | Maps private structured model output to current-source NPC interaction candidates grounded by a required registry. |
| `internal/modules/dnd/extract/scenedescriptions` | Maps one private scene description to the current accepted chunk's ID and exact range. |
| `internal/modules/dnd/npcinteractions` | Owns canonical source-reference ordering, occurrence ordering, valid-evidence checks, and exact interaction identity shared by normalization and invariant validation. |
| `internal/modules/dnd/npcinteractions` | Owns interaction occurrence ordering, valid-evidence checks, and exact interaction identity shared by normalization and invariant validation. |
| `internal/modules/dnd/normalize/combatturns` | Canonicalizes and orders merged combat turns, applies exact NPC identity matches, and collapses only exact valid-evidence duplicates. |
| `internal/modules/dnd/normalize/npcinteractions` | Canonicalizes required-registry names, orders interaction occurrences, and collapses only exact valid-evidence duplicates. |
| `internal/modules/dnd/normalize/scenedescriptions` | Trims, source-orders, and removes only exactly identical scene descriptions while rejecting ID and range conflicts. |
@@ -118,8 +118,9 @@ Configuration. The implemented module packages are:
| `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, rejections, and an explicitly enabled accepted chunk map as logical JSON files. |
`internal/modules/dnd/shared` owns reusable D&D prompt fragments,
reference declarations, prompt input assembly, source-unit reference helpers,
and bounded diagnostics under `internal/modules/dnd/shared/diagnostics`.
reference declarations, prompt input assembly, document-aware source-reference
ordering and canonicalization, and bounded diagnostics under
`internal/modules/dnd/shared/diagnostics`.
The shared NPC grounding fragment is mounted for D&D prompts and is owned by
this package. Domain-neutral prompt filesystem composition lives in
`internal/framework/promptfs`.

View File

@@ -1,826 +0,0 @@
# D&D Extraction Module Refactoring Audit Strategy
Status: Strategy complete; final audit results recorded below
## Purpose
Define a disciplined audit of the five production D&D extraction modules:
- `dnd/spells`;
- `dnd/npcs`;
- `dnd/combat-turns`;
- `dnd/npc-interactions`; and
- `dnd/scene-descriptions`.
The audit will determine whether these modules follow a coherent set of
conventions, whether repeated implementation can be replaced by appropriately
scoped shared code or assets, and whether the accumulated design suggests other
maintainability improvements.
This document defines how to perform the audit. It does not contain audit
findings and does not authorize production changes.
## Audit Principles
The audit must distinguish consistency from uniformity. The five modules should
use the same conventions where they perform the same responsibility, but a
module should remain different when its artifact semantics, evidence model,
reference requirements, or normalization policy require it.
Recommendations must follow these principles:
- Prefer evidence from current code, tests, prompts, schemas, configuration,
and documentation over naming or visual similarity.
- Treat a divergence as a finding only when it is unexplained, increases
maintenance or correctness risk, or violates an intended convention.
- Do not recommend an abstraction solely to reduce line count. A shared helper
must own one coherent invariant and make future correct changes easier.
- Preserve package ownership of artifact semantics, private response DTOs,
private structured-output schemas, and lane-specific prompts.
- Keep D&D-specific behavior in D&D packages. Move behavior into a generic
framework package only when the contract is demonstrably domain-neutral and
has a non-D&D consumer or a clear framework-owned responsibility.
- Evaluate prompt sharing by byte identity and semantic ownership. Prompt
caching benefits only when repeated message content and ordering are exactly
identical.
- Apply the testing policy to proposed refactors. Prefer behavioral protection
at stable boundaries and do not add change-detector tests for helper usage,
prompt length, exact hashes, or private file layout.
## Scope
### Primary scope
Inspect the complete package-owned implementation beneath:
- `internal/modules/dnd/extract/spells`;
- `internal/modules/dnd/extract/npcs`;
- `internal/modules/dnd/extract/combatturns`;
- `internal/modules/dnd/extract/npcinteractions`; and
- `internal/modules/dnd/extract/scenedescriptions`.
For each package, include:
- module identity, capabilities, construction, options, registration, and
execution class;
- reference-slot declarations and construction-time or operation-time
reference handling;
- checkpoint fingerprints and manifest metadata;
- request validation and structured LLM request preparation;
- private response DTOs, response-schema loading, and response mapping;
- source-reference resolution, evidence attachment, canonicalization,
ordering, and exact deduplication;
- embedded prompt manifests, shared and local assets, message order, cache
boundaries, and schema assets;
- errors, warnings, diagnostics, cloning, and mutation safety; and
- package-local tests and test support.
### Contextual scope
Inspect a neighboring component only when needed to determine ownership,
duplication, or compatibility:
- `internal/modules/dnd/shared` and focused D&D subpackages used by more than
one extractor;
- the five artifact model and codec contracts;
- corresponding merge, normalize, and validate variants;
- production registration and default validator composition;
- pipeline reference, fingerprint, and LLM contracts;
- canonical current-behavior documentation and integration contracts; and
- representative production and integration tests.
Contextual inspection is not a request to redesign every lane stage. Findings
outside extraction should be reported only when they directly explain an
extractor inconsistency or reveal a misplaced responsibility.
### Exclusions
Do not use this audit to:
- change durable artifact schemas or extraction policy;
- redesign the fixed pipeline shape or ordered-step model;
- combine distinct artifacts into a larger D&D result;
- evaluate live-model output quality;
- introduce schema generation, a dependency-injection framework, or a general
module superclass;
- move domain rules into the generic framework;
- rewrite tests merely to make their file layout look alike; or
- implement any recommended refactor.
If the audit exposes a product-contract concern, record it separately from
refactoring recommendations and identify the additional decision required.
## Comparison Method
### 1. Establish a module inventory
Create one row per module in a working comparison matrix. Record exact current
facts rather than inferred conventions:
| Dimension | Facts to record |
| --- | --- |
| Identity | Module key, artifact kind, capabilities, execution class |
| Files | Production files, embedded assets, focused test files |
| Construction | Dependencies, options, reference decoding, immutable prepared state |
| Registration | `ModuleSpec`, builder, option validation, declared slots |
| Provenance | Manifest metadata and checkpoint fingerprint keys and values |
| Prompt | Prompt ID/version, manifest messages, inputs, shared assets, cache boundaries |
| Schema | Private schema identity, strictness, loader, diagnostics behavior |
| Execution | Request validation, LLM call, response mapping, errors and warnings |
| Evidence | Source identity, range resolution, canonicalization, ordering, deduplication |
| Tests | Contract owner, malformed cases, integration coverage, test-only helpers |
Use the matrix to identify exact agreement, intentional variation, and
unexplained variation. Do not infer a preferred convention from whichever
module was implemented first. Determine the preferred shape from architecture,
current documentation, shared contracts, and the clearest implementation.
### 2. Classify every divergence
Assign each observed difference one classification:
- **Required specialization:** the artifact or reference contract requires the
difference. No harmonization is recommended.
- **Permitted variation:** implementations differ without meaningful
maintenance or correctness cost.
- **Convention drift:** equivalent responsibilities use different names,
layouts, error behavior, metadata, validation, or tests without a reason.
- **Architectural divergence:** responsibility is placed in the wrong layer or
bypasses a shared contract.
- **Undetermined:** more evidence or a policy decision is needed.
For required specialization, document the reason briefly so a future audit
does not repeatedly flag it. For drift or architectural divergence, identify
the preferred convention and why it is preferable.
### 3. Build a duplication inventory
Search for three kinds of repetition:
1. **Exact duplication:** identical Go logic, prompt text, schema fragments, or
test support.
2. **Structural duplication:** the same algorithm or lifecycle expressed with
renamed domain types.
3. **Policy duplication:** the same invariant is independently encoded in
several production or test layers.
Trace callers and consumers before recommending extraction. Record:
- the repeated responsibility;
- participating modules;
- meaningful semantic differences;
- change history or likely change cadence when discoverable;
- defect risk if copies drift;
- proposed owner and API shape; and
- code or assets that would remain module-owned.
Similar code is not sufficient evidence. Prefer a shared abstraction when at
least one of the following is true:
- three or more modules independently implement the same nontrivial invariant;
- two modules share correctness-sensitive behavior that must evolve together;
- existing duplicated code has already drifted or caused a defect; or
- an existing shared contract is being reimplemented locally.
Avoid extraction when the shared API would need artifact-specific callbacks,
large configuration objects, type erasure, or branching on module identity.
Those are signs that visual similarity is masking separate responsibilities.
## Convention Review
Evaluate the following conventions across all five modules.
### Package organization
- Comparable responsibilities use predictable filenames and package-local
ownership.
- Optional specialized files, such as catalog or registry wiring, are present
only where the module has that responsibility.
- Exported identifiers are limited to framework and registration contracts.
- Test helpers remain local unless sharing them improves test clarity without
coupling independent suites.
### Construction and registration
- Required dependencies fail during construction.
- Options are decoded and unknown options rejected consistently.
- `ModuleSpec`, reference slots, artifact kind, execution class, and builder
behavior agree with runtime behavior.
- Construction resolves stable reference-derived state where possible, while
operation requests own genuinely run- or chunk-specific inputs.
- Returned specs, metadata, fingerprints, and byte slices have consistent
defensive-copy behavior.
### Prompt and schema boundary
- Shared prompt messages come from canonical shared assets; package assets
contain only lane-specific wording.
- Shared content is exactly identical across manifests and appears in the
documented cache-friendly order.
- Stable messages and cache boundaries precede the variable transcript.
- Declared prompt inputs match reference slots and generated projections.
- Private response schemas are strict structural envelopes and reject unknown
fields.
- Semantic validation remains in deterministic code at the intended boundary.
- Schema identity, version, digest, and diagnostics are exposed consistently
without leaking schema or prompt content.
### Extraction and evidence
- Request validation and contextual error wrapping follow one recognizable
pattern.
- Reference material aids disambiguation but never becomes transcript evidence.
- Source unit IDs are resolved against document order rather than numeric
assumptions.
- Mapping performs only the deterministic transformations owned by extraction.
- Canonical source references, stable ordering, and exact deduplication use
consistent policies where artifact semantics agree.
- Whole-chunk evidence in scene descriptions is treated as an intentional
specialization rather than forced through citation-oriented helpers.
- Results do not share mutable state with model responses, references, or
requests.
### Provenance and diagnostics
- Prompt and response-schema identities and hashes appear consistently in
manifest metadata.
- Checkpoint fingerprints cover every stable semantic input that could change
accepted output, without including credentials, paths, timestamps, or source
content.
- Registry and catalog projections use canonical bytes and retain bounded
provenance.
- Errors and diagnostics are contextual, bounded, and free of raw reference or
secret content.
### Tests
- Each important contract has one clear test owner.
- Equivalent risks receive comparable coverage without requiring identical test
file layouts.
- Schema tests cover required structure and unknown-field rejection without
duplicating every semantic validator case.
- Extractor tests cover request validation, response mapping, evidence,
ordering, references, and provider failures at the narrowest stable boundary.
- Prompt tests prepare real embedded assets and protect input placement,
ordering, cache boundaries, and content-safety properties without freezing
exact prompt text.
- Registration or production tests prove assembly once and do not repeat
package-local behavior unnecessarily.
- Obsolete, redundant, representation-specific, or impossible fixtures are
identified for deletion or simplification.
## Shared-Code Decision Framework
Recommend the narrowest owner that matches the repeated responsibility:
1. Keep artifact semantics in the module that owns the artifact.
2. Use `internal/modules/dnd/shared` for D&D-wide mechanics with identical
semantics, such as prompt inputs or source-unit reference handling.
3. Use a focused D&D subpackage for behavior shared by a subset of lanes, such
as NPC registry projection, when it has a coherent domain contract.
4. Use a framework package only for transport-neutral or domain-neutral
behavior owned by the framework.
Evaluate these candidate categories without assuming they should be extracted:
- extractor request precondition validation;
- private response-schema loading and metadata assembly;
- prompt asset registration and hashing;
- source-reference conversion, canonical ordering, and exact deduplication;
- manifest metadata and checkpoint fingerprint assembly;
- NPC registry resolution and names-only projection;
- strict option decoding and module registration;
- diagnostic redaction and bounded error context; and
- repeated test fixtures or schema-validation utilities.
For prompts and schemas:
- Factor prompt wording into a shared asset only when every consumer needs the
exact same text and should receive future changes atomically.
- Prefer an existing shared asset over a new near-duplicate.
- Do not create shared prompt fragments solely because prose is similar.
- Keep private response schemas package-owned unless a genuine shared wire
contract exists.
- Do not introduce shared JSON Schema fragments or generation unless the audit
demonstrates a maintenance problem that outweighs tooling and indirection.
## Additional Quality Review
Beyond consistency and duplication, inspect:
- functions with high cognitive complexity or responsibilities that can be
separated without obscuring the extraction flow;
- repeated linear scans inside loops, avoidable serialization, unnecessary
allocations, or per-chunk reconstruction of stable state;
- hidden mutation, aliasing, or inconsistent clone boundaries;
- fingerprint omissions that could permit stale checkpoint reuse;
- prompt inputs or references repeated unnecessarily across messages;
- unreachable defensive checks or validation performed redundantly at several
layers;
- errors that lose module, field, source, or stage context;
- stale documentation, fixtures, names, compatibility aliases, or comments;
- public or package abstractions that have only one artificial consumer; and
- opportunities to delete code after a shared helper replaces it.
Performance recommendations must identify a plausible workload and complexity
impact. Do not recommend micro-optimization without evidence.
## Evidence Collection
Perform the audit in this order:
1. Read the architecture, testing, documentation, module, LLM, pipeline, and
relevant integration contracts.
2. Build the five-module comparison matrix from definitions and assets.
3. Use graph similarity only to identify candidates; read the complete
functions and trace their callers before classifying them.
4. Compare prompt manifests and shared assets byte-for-byte, then compare local
prompt semantics.
5. Compare private schemas structurally and map each field to its DTO, mapper,
durable artifact, and validator owner.
6. Trace reference and fingerprint data from construction through the LLM
request and checkpoint identity.
7. Review focused tests alongside the behavior they own.
8. Run the existing focused and repository-wide validation commands to
distinguish current failures from maintainability observations.
Do not modify production code, tests, prompts, schemas, examples, or
current-behavior documentation during the audit.
## Finding Standard
Every reported finding must contain:
- severity: high, medium, or low;
- category: convention drift, duplication, architecture, correctness,
performance, testing, or documentation;
- affected modules and exact file or symbol references;
- observed behavior and the convention or invariant it is compared against;
- concrete maintenance, correctness, cost, or security impact;
- recommended target state and ownership;
- why the recommendation is preferable to leaving the code separate; and
- validation or migration considerations.
Order findings by severity and impact, not by module. Separate confirmed
findings from optional improvements. State explicitly when no issue is found in
a comparison area.
For each apparent duplication, the audit must choose one outcome:
- extract now;
- harmonize without sharing;
- retain intentionally separate; or
- defer pending a named missing requirement.
Do not report speculative abstractions as findings. Record them, if useful, as
rejected or deferred candidates with the reason.
## Audit Deliverable
The completed audit should provide:
1. an executive conclusion addressing convention consistency, shared-code
opportunities, and overall code quality;
2. the completed five-module comparison matrix;
3. prioritized findings with evidence and recommendations;
4. intentional differences that should be preserved;
5. rejected or deferred sharing candidates and rationale;
6. a proposed refactoring sequence grouped into independently safe changes;
and
7. validation commands and any residual risks.
The audit should be actionable enough to support a later decision-complete
implementation plan, but it must not implement or silently commit any
recommendation.
## Completion Criteria
The strategy has been followed when:
- all five primary packages and their prompts, schemas, registration,
provenance, reference handling, mapping, and tests have been compared;
- every divergence has a classification;
- every repeated candidate has an ownership and keep/share decision;
- architecture and testing-policy constraints are applied explicitly;
- findings cite exact evidence and explain impact;
- intentional specialization is documented alongside drift;
- repository validation results are recorded; and
- no production changes were made as part of the audit.
## Audit Results
Status: Complete
### Executive Conclusion
The five D&D extraction modules follow a coherent overall convention: all are
typed, production-registered extractors with strict private response schemas,
package-owned artifact mapping, bounded provenance, shared canonical prompt
assets, defensive result ownership, and deterministic downstream validation.
Their differences in catalog/registry state, reference slots, local prompt
assets, whole-chunk versus cited evidence, and scene prose cleanup are explained
by artifact semantics and should remain.
The consistency question is therefore **mostly yes, with two material
exceptions**. Spell artifact ordering and all four citation extractors'
reference ordering assume numeric unit IDs instead of source-document order,
and spell/NPC checkpoint identities omit stable mapping policies. Both can
change durable results or reuse results produced under different semantics.
Smaller drift exists in common request preflight, scene semantic-validation
ownership, prompt cache-boundary coverage, package surface conventions, and
the documentation of the common extractor contract for future lanes.
The shared-code question is **yes, but only for two narrow D&D-wide
responsibilities**: common chunk-extraction preflight and document-aware
source-reference ordering/canonicalization. They belong in
`internal/modules/dnd/shared`, not in the framework. Private response DTOs,
artifact mapping, structured LLM calls, prompt prose, response schemas,
metadata assembly, error prefixes, and typed test fakes should remain
package-owned; sharing them would require callbacks, type erasure, module
branching, or broad configuration.
Overall code quality is **good**. Construction, registration, cloning,
diagnostics, schema loading, prompt composition, and validator composition are
clear and consistently tested. The repository passes all current validation.
The recommended work is targeted correction and consolidation, not a redesign
of the extractor family.
### Final Module Comparison Matrix
| Module | Common contract | Required specialization | Prompt/schema boundary | Evidence and ordering | Provenance and tests |
| --- | --- | --- | --- | --- | --- |
| Spells | `dnd/spells` -> `dnd/spell-list`; typed LLM extractor; strict private DTO/schema; append merge and typed normalize/validate ([spec](../../internal/modules/dnd/extract/spells/extractor.go#L207)) | Prepared spell catalog and optional NPC registry projection; catalog prompt input ([constructor](../../internal/modules/dnd/extract/spells/extractor.go#L73)) | Shared evidence/input messages plus local task, instructions, catalog, and transcript; semantic catalog validation remains deterministic ([manifest](../../internal/modules/dnd/extract/spells/assets/prompts/dnd.spells.yaml#L23)) | Attaches current transcript ID and preserves invalid candidates, but sorts artifacts/references by numeric unit ID rather than document position ([canonicalization](../../internal/modules/dnd/extract/spells/canonicalize.go#L10)) | Prompt/schema/catalog/NPC projection fingerprints, but no mapping-policy fingerprint; broad extractor tests, incomplete full prompt cache-order assertion ([fingerprints](../../internal/modules/dnd/extract/spells/extractor.go#L144)) |
| NPCs | `dnd/npcs` -> `dnd/npc-list`; same typed lifecycle ([spec](../../internal/modules/dnd/extract/npcs/extractor.go#L159)) | Deterministic NPC identity derivation; campaign references remain request context ([mapping](../../internal/modules/dnd/extract/npcs/canonicalize.go#L94)) | Shared evidence/input messages plus NPC-local task/instructions/transcript; private schema owns transport only ([manifest](../../internal/modules/dnd/extract/npcs/assets/prompts/dnd.npcs.yaml#L17)) | Artifact order resolves document positions; reference list still sorts numeric IDs before exact deduplication ([canonicalization](../../internal/modules/dnd/extract/npcs/canonicalize.go#L11)) | Prompt/schema/identity fingerprints, but no mapping-policy fingerprint; weakest focused preflight matrix and partial prompt ordering coverage ([fingerprints](../../internal/modules/dnd/extract/npcs/extractor.go#L91)) |
| Combat turns | `dnd/combat-turns` -> `dnd/combat-turn-list`; same typed lifecycle ([spec](../../internal/modules/dnd/extract/combatturns/extractor.go#L201)) | Optional prepared NPC registry; all combat kinds remain raw validator candidates ([constructor](../../internal/modules/dnd/extract/combatturns/extractor.go#L68)) | Shared evidence/NPC/input messages plus combat-local task/instructions/transcript ([manifest](../../internal/modules/dnd/extract/combatturns/assets/prompts/dnd.combat_turns.yaml#L20)) | Artifact order uses valid source positions; extractor, normalizer, and invariant validator duplicate differing reference-order mechanics ([extractor](../../internal/modules/dnd/extract/combatturns/canonicalize.go#L10), [normalizer](../../internal/modules/dnd/normalize/combatturns/normalizer.go#L228)) | Mapping policy is fingerprinted; complete request preconditions, mapping, registry, and provider tests, but incomplete full prompt cache-order assertion ([fingerprints](../../internal/modules/dnd/extract/combatturns/extractor.go#L126)) |
| NPC interactions | `dnd/npc-interactions` -> `dnd/npc-interaction-list`; same typed lifecycle ([spec](../../internal/modules/dnd/extract/npcinteractions/extractor.go#L203)) | Required NPC registry and names-only projection; exact identity remains in focused domain package ([constructor](../../internal/modules/dnd/extract/npcinteractions/extractor.go#L68)) | Shared evidence/NPC/input messages plus interaction-local task/instructions/transcript ([manifest](../../internal/modules/dnd/extract/npcinteractions/assets/prompts/dnd.npc_interactions.yaml#L20)) | Extractor references sort numeric IDs, while the interaction model already owns correct document-aware canonicalization at too-narrow a layer ([extractor](../../internal/modules/dnd/extract/npcinteractions/canonicalize.go#L10), [model helper](../../internal/modules/dnd/npcinteractions/canonical.go#L17)) | Mapping, identity, prompt, schema, and registry projection are fingerprinted; focused suite omits nil-receiver and full prompt cache-order cases ([fingerprints](../../internal/modules/dnd/extract/npcinteractions/extractor.go#L126)) |
| Scene descriptions | `dnd/scene-descriptions` -> `dnd/scene-description-list`; same typed lifecycle ([spec](../../internal/modules/dnd/extract/scenedescriptions/extractor.go#L174)) | One summary per chunk, whole-chunk evidence, and fingerprinted prose trimming; no catalog/NPC projection ([mapping](../../internal/modules/dnd/extract/scenedescriptions/extractor.go#L150)) | Local task/instructions plus shared input/transcript; schema currently duplicates semantic enum/non-empty policy owned by shape validation ([schema](../../internal/modules/dnd/extract/scenedescriptions/assets/schemas/dnd_scene_descriptions_llm.v1.json#L8), [validator](../../internal/modules/dnd/validate/scenedescriptions/shape/validator.go#L49)) | Whole materialized chunk becomes one source range; citation ordering is not applicable | Mapping policy is fingerprinted; strongest full prompt order/cache test, but preflight omits nil receiver/context cases ([fingerprints](../../internal/modules/dnd/extract/scenedescriptions/extractor.go#L98), [prompt test](../../internal/modules/dnd/extract/scenedescriptions/scriptorium_assets_test.go#L15)) |
All five reject unknown options, register through the production D&D registrar,
return independently owned results, and use package-local structured response
types. No inconsistent secret handling, raw prompt/schema diagnostic exposure,
hidden result aliasing, or unregistered audited extractor was found.
### Prioritized Findings
#### High
1. **Source references and spell artifacts can be durably ordered contrary to
transcript order.**
- **Category:** correctness and architecture.
- **Affected modules:** spell, NPC, combat-turn, and NPC-interaction
extractors; related spell/combat/NPC normalizers and combat invariants.
- **Evidence:** spell selects the smallest positive numeric unit ID
([`earliestSourceUnit`](../../internal/modules/dnd/extract/spells/canonicalize.go#L62));
all four citation extractors sort reference endpoints numerically
([spell](../../internal/modules/dnd/extract/spells/canonicalize.go#L30),
[NPC](../../internal/modules/dnd/extract/npcs/canonicalize.go#L31),
[combat](../../internal/modules/dnd/extract/combatturns/canonicalize.go#L30),
[interaction](../../internal/modules/dnd/extract/npcinteractions/canonicalize.go#L30)).
Valid source documents require unique positive IDs, not monotonically
increasing IDs
([`ValidateDocument`](../../internal/core/source/validation.go#L8)).
The interaction model demonstrates the correct document-aware comparison
([`SourceRefLess`](../../internal/modules/dnd/npcinteractions/canonical.go#L51)).
- **Impact:** valid evidence can be reordered away from transcript order;
spell casts with invalid or later evidence can precede earlier valid
casts. This changes durable list order, evidence presentation, merge input,
and checkpointed results.
- **Target state and owner:** move a document-backed `SourceRefOrder` to
`internal/modules/dnd/shared`, with `EarliestValid` and `Canonicalize`
operations. Preserve invalid candidates, exact deduplication, stable ties,
nil/empty distinction, cloning, and deterministic invalid fallback.
Artifact comparison, DTO conversion, source-ID attachment, and repair
accounting remain local.
- **Why shared:** the rule is a D&D-wide evidence invariant already
implemented by extract, normalize, and validate consumers; continued
copies have already diverged. A shared position index also reduces repeated
`UnitIndex` scans from approximately `O(A log A * R * U)` ordering work to
`O(U + A*R + A log A)` for `A` artifacts, `R` references, and `U` units.
- **Migration/validation:** add non-monotonic, invalid, duplicate, nil/empty,
aliasing, stable-tie, and repair-count tests; migrate the interaction model
and combat normalize/invariant pair before extractors; add/bump mapping
policy fingerprints so old checkpoints miss intentionally.
2. **Spell and NPC checkpoint identities omit stable mapping policies.**
- **Category:** correctness and data integrity.
- **Affected modules:** spell and NPC extractors.
- **Evidence:** both extractors perform deterministic ordering,
canonicalization, source attachment, and mapping
([spell mapping](../../internal/modules/dnd/extract/spells/canonicalize.go#L10),
[NPC mapping](../../internal/modules/dnd/extract/npcs/canonicalize.go#L11)),
but their fingerprint providers name catalog/projection or identity
policies without a mapping policy
([spell fingerprints](../../internal/modules/dnd/extract/spells/extractor.go#L144),
[NPC fingerprints](../../internal/modules/dnd/extract/npcs/extractor.go#L91)).
Prepared fingerprints are lane-scoped
([collector](../../internal/framework/pipeline/prepared_fingerprints.go#L23))
and restore requires exact normalized equality
([comparison](../../internal/framework/checkpoint/loader.go#L301)).
- **Impact:** a mapping-policy code change can reuse a checkpoint produced
under older artifact/evidence semantics when prompt and schema bytes are
unchanged.
- **Target state and owner:** each extractor owns an explicit stable
mapping/canonicalization policy fingerprint. Metadata assembly stays
package-local.
- **Why preferable:** a local named semantic fingerprint directly closes
the reuse gap; a generic metadata builder would only hide module-specific
omissions behind configuration.
- **Migration/validation:** add provider and prepared-checkpoint restore
tests. Adding a fingerprint safely invalidates existing identities by full
list mismatch; no artifact payload migration is required.
#### Medium
3. **Common extraction preflight is copied across five production callers and
has already drifted in test protection.**
- **Category:** duplication and testing.
- **Affected modules:** all five extractors.
- **Evidence:** every `Extract` validates the same context/source/chunk/unit
prerequisites before calling
[`ChunkPromptMaterial`](../../internal/modules/dnd/shared/extraction_inputs.go#L12),
but focused coverage ranges from a complete spell/combat matrix to only
cancellation and source mismatch for NPCs
([spell tests](../../internal/modules/dnd/extract/spells/extractor_test.go#L235),
[NPC tests](../../internal/modules/dnd/extract/npcs/extractor_test.go#L139)).
- **Impact:** validation order, error behavior, or a newly required common
precondition can diverge silently among modules.
- **Target state and owner:** add
`shared.PrepareChunkExtraction(ctx, req) (contracts.LLMInputMaterial,
error)` for cancellation, non-nil source/chunk, non-empty units, and
matching cloned material. Receiver/client checks, specialized references,
provider calls, typed results, and contextual wrapping remain local.
- **Why shared:** this extends the existing common material boundary with
one coherent invariant and serves five real callers without callbacks or
module configuration.
- **Migration/validation:** shared table tests own common inputs; package
tests retain nil receiver/dependency, specialized reference, wrapped
error, and provider cases. Preserve current validation order and useful
package context.
4. **Scene semantic validity has two production owners.**
- **Category:** architecture.
- **Affected module:** scene descriptions.
- **Evidence:** the private response schema enforces the scene-kind enum and
non-empty title/summary
([schema](../../internal/modules/dnd/extract/scenedescriptions/assets/schemas/dnd_scene_descriptions_llm.v1.json#L8));
deterministic shape validation independently enforces the same policy
([validator](../../internal/modules/dnd/validate/scenedescriptions/shape/validator.go#L49)).
The documented schema boundary assigns semantic enum/non-empty rules to
deterministic validators
([LLM internals](../internal/llm.md#L169)).
- **Impact:** the two policies can drift and produce provider-dependent
rejection before typed validation, while non-LLM artifacts see only the
validator.
- **Target state and owner:** retain JSON type, required/nullability, and
unknown-field constraints in the package-private schema; make the scene
shape validator the sole semantic owner.
- **Why harmonize without sharing:** only scene has this duplicated policy;
moving it to the existing validator removes an owner without inventing an
abstraction.
- **Migration/validation:** update schema structural tests and validator
semantic tests, then expect the schema digest/checkpoint identity to
change. Verify provider-decoded invalid candidates reach deterministic
validation.
#### Low
5. **Four prompt suites do not fully protect the documented message-order and
cache-boundary contract.**
- **Category:** testing.
- **Affected modules:** spells, NPCs, combat turns, and NPC interactions.
- **Evidence:** scene descriptions asserts the complete prepared role order
and ephemeral/no-cache placement
([test](../../internal/modules/dnd/extract/scenedescriptions/scriptorium_assets_test.go#L15));
the four citation suites cover registration and selected inputs but not
the full documented sequence
([spell tests](../../internal/modules/dnd/extract/spells/scriptorium_assets_test.go),
[NPC tests](../../internal/modules/dnd/extract/npcs/scriptorium_assets_test.go),
[combat tests](../../internal/modules/dnd/extract/combatturns/scriptorium_assets_test.go),
[interaction tests](../../internal/modules/dnd/extract/npcinteractions/scriptorium_assets_test.go)).
- **Impact:** manifest edits can move variable content into a cacheable
prefix or reorder stable grounding without a focused failure, increasing
request cost or reducing prompt quality.
- **Target state and owner:** each package test should assert its complete
documented prepared sequence and cache flags using the real registry.
- **Why local:** lane inputs differ and the behavior belongs to each prompt
manifest; shared test setup would obscure the boundary.
- **Migration/validation:** assert roles/input identities/cache flags, not
exact prompt text, byte counts, or hashes.
6. **Small exported-surface and defensive-behavior drift remains.**
- **Category:** convention drift.
- **Affected modules:** spells, combat turns, and D&D shared.
- **Evidence:** spell and combat export unused singular `ArtifactType`
constants while production uses typed `ArtifactKind`
([spell](../../internal/modules/dnd/extract/spells/extractor.go#L17),
[combat](../../internal/modules/dnd/extract/combatturns/extractor.go#L17));
spell `ManifestMetadata` lacks the nil guard used by the other four
([spell](../../internal/modules/dnd/extract/spells/extractor.go#L122),
[NPC example](../../internal/modules/dnd/extract/npcs/extractor.go#L74));
[`SourceRefCandidate`](../../internal/modules/dnd/shared/unit_refs.go#L90)
has only a test caller, ignores its document parameter, and is unsafe for
extractor provenance because it trusts model-supplied source identity.
- **Impact:** the package surface presents competing artifact vocabulary,
zero-value behavior is inconsistent, and an artificial shared API invites
incorrect reuse. Immediate runtime impact is limited.
- **Target state and owner:** delete the unused constants; align spell nil
metadata behavior locally; delete `SourceRefCandidate` when the correct
shared reference API lands.
- **Why preferable:** deletion and local harmonization clarify existing
contracts without adding a helper.
- **Migration/validation:** graph search found no production consumers;
compile all internal packages and add one spell zero-value metadata test.
7. **The common D&D extractor contract is observable but only partially
documented as a requirement for future lanes.**
- **Category:** documentation and convention drift.
- **Affected modules:** all five extractors and future D&D extraction
modules.
- **Evidence:** all five reject unknown options, perform common request
preflight, return independently owned results, use package-local private
response types, and expose prompt/schema provenance plus checkpoint
fingerprints. The extension checklist in
[module internals](../internal/modules.md#adding-an-extension) covers
registration, package-owned assets, prompt ordering, and general option
and validation coverage, while
[LLM internals](../internal/llm.md#prompt-and-schema-assets) documents the
private-schema boundary. Neither location consolidates the remaining
behaviors into a normative D&D extractor contract. The missing spell/NPC
mapping fingerprints and uneven preflight and prompt-contract coverage
demonstrate that conventions discoverable from current packages can
still drift.
- **Impact:** an additional extractor can appear locally consistent while
silently accepting misspelled options, omitting a stable semantic input
from checkpoint identity, returning aliased mutable data, implementing
incomplete preflight, or missing focused contract coverage.
- **Target state and owner:** add a compact **D&D extractor contract**
subsection to `docs/internal/modules.md`, incorporated into or placed
immediately after **Adding An Extension**. Specify behavioral
responsibilities rather than filenames or boilerplate:
- reject unknown options unless the option namespace is intentionally
extensible;
- use the shared request preflight contract while retaining receiver,
dependency, and lane-specific checks locally;
- return results and exposed metadata that are independently owned and
safe for caller mutation;
- keep the private response DTO, structural schema, schema identity,
provider-response mapping, durable artifact conversion, and
lane-specific diagnostics package-owned;
- include every stable semantic input that can change durable results in
checkpoint identity, including prompt, schema, mapping,
canonicalization, prepared reference projection, identity,
normalization, and trimming policies when applicable; and
- consider focused behavioral coverage for construction and
registration, option rejection, preflight, provider failures,
structured-output decoding, mapping and ownership, prompt
role/input/cache order, and checkpoint invalidation.
- **Why documentation rather than another abstraction:** these are shared
obligations, not one shared implementation. A checklist makes omissions
visible without introducing a configurable metadata builder, generic
mapper, shared test fixture, mandatory file layout, or exact-output
change-detector tests. Detailed prompt, schema, pipeline, and testing
policies should remain linked rather than duplicated.
- **Migration/validation:** update the extension checklist and relevant
cross-links when the shared preflight helper is documented. Review the
text against all five lanes and the completed audit matrix. Do not require
exact prompt text, hashes, prefix lengths, test counts, filenames, or
fixture layouts.
#### Optional improvement
The current module documentation lists only four production consumers of
`ChunkPromptMaterial`, omitting scene descriptions
([documentation](../internal/modules.md#L67)). Correct that list when the shared
preflight boundary is documented. This is localized documentation maintenance,
not a separate production design finding.
No actionable issue was found in option strictness, production registration,
typed codec selection, result cloning, prompt/schema content redaction, local
prompt semantic ownership, catalog/NPC source-evidence separation, provider
error wrapping, or secret handling.
### Intentional Differences To Preserve
- **Prepared specialized state:** spells retains a catalog and optional NPC
resolver; combat retains an optional NPC resolver; interactions requires one;
NPC and scene extraction need only request material. Construction-time
identity belongs only where the artifact uses it.
- **Reference slots:** catalog and NPC registry slots follow grounding needs.
The interaction registry is required; combat/spell registry use is optional.
Campaign references remain prompt context and never become evidence.
- **Evidence model:** scene descriptions cites the whole materialized chunk;
forcing it through citation DTO/canonicalization machinery would weaken its
one-summary-per-chunk contract.
- **Prompt assets:** only byte-identical canonical messages are shared. Local
task, instruction, catalog, identity, and evidence wording changes with the
artifact and must not be coupled.
- **Private schemas and DTOs:** similar `source_refs` fragments do not form a
separately versioned wire contract. Package ownership keeps response changes
aligned with mapping and diagnostics.
- **Prose handling:** scene title/summary trimming is a deliberate fingerprinted
mapping policy; citation-lane names and enum candidates remain raw for their
normalizers and validators.
- **Test organization:** file layout, typed fakes, schema compilation helpers,
and real-registry prompt setup remain package-local. Equivalent risks need
comparable coverage, not identical fixtures or filenames.
### Shared-Code Decisions
| Decision | Owner and scope | Why this is the narrow correct boundary |
| --- | --- | --- |
| Extract common preflight | `internal/modules/dnd/shared`; validate common typed extraction request state and return cloned matching `LLMInputMaterial` | Five current callers repeat one prerequisite to the existing `ChunkPromptMaterial` boundary. Receiver/dependency/specialized checks remain readable and local. |
| Extract source-reference order | `internal/modules/dnd/shared`; document-backed `SourceRefOrder.EarliestValid` and `.Canonicalize` over durable `[]source.SourceRef` | Multiple extract, normalize, model, and validate consumers must evolve together; the rule is D&D-wide but has no demonstrated non-D&D/framework consumer. |
| Harmonize mapping fingerprints | Spell and NPC extractor packages | The missing values are module-semantic; local named fingerprints are safer than a configurable metadata builder. |
| Harmonize scene validation ownership | Private scene schema plus `validate/scenedescriptions/shape` | Remove semantic keywords from the transport schema and keep the already registered durable validator as sole owner. No sharing is needed. |
| Harmonize prompt contract tests | Four citation extractor test suites | Each manifest owns its role/input/cache sequence; consistent assertions should stay beside distinct assets. |
| Harmonize preflight coverage | All five package suites after shared preflight | Shared tests own common branches; local tests retain package-visible context and specializations. |
| Harmonize package hygiene | Spell/combat extractors and D&D shared | Delete unused exports/artificial API and align spell metadata nil behavior without introducing a new abstraction. |
| Document the D&D extractor contract | `docs/internal/modules.md`, linked to existing LLM and testing policies | The five lanes share behavioral obligations that should guide future extensions, but they do not justify a generic implementation or rigid package template. |
### Rejected Or Deferred Candidates
| Candidate | Decision and rationale |
| --- | --- |
| Deprecated `roster` slot asymmetry | **Deferred pending a product compatibility decision.** Configuration documents `roster` as a deprecated `party` alias ([config](../config.md#L526)), while scene removes it. Choose a removal release or uniform alias lifetime before changing slots and migration guidance. |
| Spell response-schema key/ID naming | **Deferred pending a provenance migration decision.** The spell names differ from the `_llm`/`.llm` convention, but schema identity is persisted metadata. Renaming needs an alias or an explicit manifest/checkpoint compatibility break. |
| Prompt registration/hash wrapper | **Rejected.** `shared.PromptAssetManifest` already owns coherent composition/hashing. Hiding filesystems, registries, `sync.Once`, and diagnostic nouns would require broad configuration. |
| Manifest/fingerprint builder | **Rejected.** Stable inputs are module-specific; a generic builder would accept the same keys/policies as arguments and could conceal omissions such as the current spell/NPC gap. |
| Generic artifact mapper | **Rejected.** Private DTO fields, identity, enums, and artifact types require callbacks, type erasure, or module branching. Only reference ordering is genuinely common. |
| Generic structured-LLM call wrapper | **Rejected.** Generic output and request configuration would hide an already clear framework client boundary without removing semantic work. |
| Shared error-prefix helper | **Rejected.** It would add a module-name parameter to replace three transparent lines and weaken local diagnostic ownership. |
| Shared response schemas or prompt prose | **Retain intentionally separate.** Similar structure/text is not an atomic shared contract; private schema and lane prompt changes should not propagate together. |
| Shared schema-test or provider-fake utilities | **Retain intentionally separate.** Typed DTOs and package assets are the behavior under test; central fixtures would couple suites and obscure failures. |
| Framework-level preflight/reference API | **Rejected.** No non-D&D consumer or framework-owned invariant was found. The D&D shared layer preserves dependency direction and domain ownership. |
### Recommended Refactoring Sequence
1. **Land checkpoint identity protection independently.** Add spell and NPC
mapping-policy fingerprints with provider and checkpoint-restore tests.
This is a small package-local change and protects all later semantic
migrations from stale reuse.
2. **Consolidate source-reference mechanics.** Add and exhaustively test the
D&D shared document-position index/API. Migrate the existing interaction
model helper first, then combat normalize plus invariants, then NPC/spell
normalizers, and finally the four citation extractors. Keep package mapping
and repair accounting local; remove numeric comparators and the unused
`SourceRefCandidate` only after all consumers move.
3. **Consolidate common extraction preflight.** Extend the existing shared
chunk-material boundary, migrate one extractor to establish error/validation
compatibility, then migrate the remaining four. Move common branch tables
to shared tests and retain package-context smoke tests.
4. **Resolve scene validation ownership independently.** Adjust schema tests,
remove semantic enum/non-empty constraints from the private schema, retain
validator cases, and verify the expected schema fingerprint/checkpoint miss.
5. **Close prompt contract coverage.** Add full role/input/cache-boundary
assertions to each citation prompt suite without shared fixtures or exact
text/hash assertions.
6. **Apply low-risk package cleanup.** Remove unused `ArtifactType` constants
and add the spell metadata nil guard/test.
7. **Document the common extractor contract.** Update module documentation for
the shared helper consumers and boundaries, then add the D&D extractor
contract checklist described in Finding 7. Link to existing prompt, schema,
pipeline, and testing policies rather than copying them.
Each scope can be reviewed and reverted independently. The sequence is not a
decision-complete implementation plan; implementation should still pin exact
fingerprint values, exported names, error compatibility, and per-package test
cases.
### Validation And Residual Risks
Final validation on 2026-07-24:
```text
go test -count=1 ./... PASS
go vet ./... PASS
go build ./cmd/notarius PASS
gofmt -l . PASS (no files listed)
git diff --check PASS
git diff --no-index --check /dev/null docs/roadmap/audit.md
PASS (no whitespace errors; exit 1 denotes differences)
```
The audit also checked that cited relative paths exist in the current working
tree. The audit changed no production code, tests, prompts, schemas, examples,
or current-behavior documentation.
Residual risks and limits:
- This was a static and deterministic-test audit; it did not evaluate live
model extraction quality, prompt effectiveness, provider cache-hit rates, or
token cost. Prompt changes still need representative human/model evaluation.
- Existing tests use mostly monotonic unit IDs, so the ordering defect is not a
current failing test. The proposed non-monotonic fixtures are required before
changing behavior.
- The exact durable order expected for invalid references must remain
deterministic and diagnostics-friendly during API design; invalid references
must not be discarded merely because default validators usually reject them
later.
- Mapping and schema fingerprint additions intentionally invalidate prior
checkpoint identities. Operators should be told to expect recomputation; no
serialized artifact migration is otherwise indicated.
- The `roster` alias lifetime and spell schema-identity migration remain human
product/compatibility decisions.
- Passing repository checks establishes current deterministic correctness, not
absence of model-quality regressions or correctness under unrepresented
source-document shapes.

View File

@@ -1,200 +0,0 @@
# D&D NPC Interactions
Status: Implemented
The current durable and configuration contract is documented in the
[D&D NPC interaction artifact](../integrations/dnd-npc-interaction-artifacts.md).
## Purpose
The normalized NPC registry intentionally answers only who was identified in a
session. It does not answer whether an NPC was merely mentioned, participated
in dialogue, or fought alongside or against the party. Add a separate,
ordered NPC-interaction artifact for that occurrence-level information rather
than expanding the identity registry.
This feature preserves the minimal-extractor policy: the model identifies one
bounded kind of interaction and its supporting transcript evidence. It does
not summarize the interaction, infer relationships, or maintain NPC state.
## Desired End State
A D&D pipeline can run NPC extraction first and supply its accepted normalized
registry to a later `dnd/npc-interactions` lane. The later lane emits an ordered
list of evidenced interaction occurrences involving registry NPCs.
The production identities should be:
- extractor and normalizer key: `dnd/npc-interactions`;
- artifact kind: `dnd/npc-interaction-list`;
- durable schema ID: `notarius.dnd.npc_interactions`;
- durable schema name: `notarius_dnd_npc_interactions_v1`;
- durable schema version: `v1`; and
- media type: `application/json`.
The lane must use the existing D&D module organization, shared prompt assets,
typed artifact pipeline, codec boundary, registration pattern, and default
validator composition.
## Artifact Contract
The durable payload is an object containing an `interactions` array. The array
may be empty. Each interaction contains exactly:
- `name`: the canonical NPC name from the supplied registry;
- `kind`: one value from the bounded interaction vocabulary; and
- `source_refs`: one or more current-transcript ranges supporting both the NPC
identity and the classified interaction.
Every object rejects unknown fields. The model-facing response should contain
only the corresponding name, kind, and source-unit range candidates. Notarius
attaches the current source identity deterministically; the model must not
reproduce it.
Do not add a separate interaction ID in the durable contract. Stable ordering,
the canonical NPC name, the bounded kind, and exact evidence ranges are enough
to identify and audit an occurrence for the present use cases. Revisit durable
cross-artifact identity only with a concrete consumer requirement.
## Interaction Vocabulary
Use this closed vocabulary:
| Kind | Meaning |
| --- | --- |
| `mentioned` | The NPC is referred to, but is not established as present or communicating in the evidenced passage. |
| `noncombat_presence` | The NPC is present and relevant to the passage but does not meaningfully participate in dialogue or combat. |
| `dialogue` | The NPC speaks, responds, or is directly engaged in a meaningful non-combat exchange. |
| `combat_ally` | The NPC actively participates in combat on the party's side. |
| `combat_opponent` | The NPC actively participates in combat against the party. |
| `other` | The transcript clearly establishes a direct NPC occurrence that fits none of the preceding kinds. |
`other` is a residual category for positively evidenced activity, not an
escape hatch for uncertain classification. Omit a candidate when the
transcript does not support one category.
When activities overlap within one occurrence, apply this precedence:
1. active combat participation outranks dialogue, presence, and mention;
2. dialogue outranks non-combat presence and mention;
3. non-combat presence outranks mention; and
4. `other` applies only when none of the defined categories describes the
evidenced activity.
Combat alignment is not resolved by precedence. An NPC cannot be both a combat
ally and combat opponent in one occurrence; split the record when its alignment
meaningfully changes.
## Occurrence Boundaries And Ordering
An occurrence represents one NPC, one interaction kind, and one locally
coherent transcript passage. Combine repeated evidence only while it supports
the same uninterrupted activity. Create separate occurrences when:
- the interaction kind changes;
- combat alignment changes;
- a scene or meaningful absence separates repeated activity; or
- the NPC is first mentioned and later becomes present.
Extraction remains chunk-scoped, so an occurrence must not span accepted chunk
boundaries. Merge and normalization must not semantically combine occurrences
from different chunks.
Order the durable list by the earliest valid source position. Use canonical
name, interaction kind, and canonical source ranges as deterministic
tie-breakers. Remove only exact duplicate records; do not use model judgment to
collapse nearby occurrences.
## NPC Grounding And Evidence
The extractor declares the existing `npcs` reference slot and requires a bound,
accepted NPC registry. The normal same-run configuration binds the normalized
output of an earlier NPC lane through an ordered generated reference. Existing
framework support for a compatible external registry may remain available, but
must not weaken the artifact contract.
Present the registry's names-only projection to the model. An emitted name must
match one canonical registry name exactly after the registry's established
lookup rules are applied, and the durable artifact retains that canonical
display name. Do not copy NPC IDs or registry source references into an
interaction.
The registry establishes available identity, not occurrence. Every interaction
must cite current transcript ranges that independently support both the NPC and
the selected kind. Campaign references and generated artifacts may disambiguate
a name, but they never become interaction evidence.
If no registry NPC has an evidenced interaction, the correct artifact is an
empty list. A missing, rejected, or incompatible required registry handoff is a
pipeline dependency failure rather than a request to extract ungrounded names.
## Prompt And Model Boundary
Follow the established D&D prompt ordering and cache-boundary policy. Stable
shared instructions, lane instructions, campaign references, and the NPC
names-only projection precede the variable transcript. Factor wording shared
with the spell and combat lanes into the existing shared asset pattern rather
than creating nearly identical package-local messages.
The prompt must:
- define the closed vocabulary and precedence rules;
- ask for occurrences involving only supplied registry NPCs;
- distinguish mention from presence and dialogue;
- require current-transcript evidence for identity and kind;
- forbid summaries, relationship inference, sentiment, aliases, and invented
names; and
- permit an empty result.
Keep the private JSON Schema structural. Deterministic code owns canonical-name
resolution, enum enforcement at the durable boundary, source-range validation,
ordering, exact deduplication, and evidence invariants.
## Validation And Quality
Provide production validators and default chains at extraction and
normalization boundaries consistent with the existing D&D artifacts. The
append-only merge does not require a separate default validator chain.
Deterministic validation must reject:
- missing or extra fields;
- empty or unrecognized NPC names;
- names absent from the supplied registry;
- unknown interaction kinds;
- empty, malformed, out-of-source, or reversed evidence ranges; and
- records whose evidence comes from a reference rather than the current
transcript.
Any relatedness validator should remain warning-only unless evaluation
demonstrates a reliable deterministic rejection rule. Diagnostics must be
bounded and must not leak reference contents.
Evaluate the lane on a small human-reviewed transcript set that includes every
category, transitions between categories, multiple occurrences for one NPC,
mentions followed by appearances, alignment changes, repeated evidence across
chunks, and empty output. Review category agreement, evidence sufficiency,
ordering, duplicate behavior, and reliability on the smaller models the
application is intended to support. Treat model-output evaluation as a human
development aid, not a brittle deterministic test oracle.
## Documentation Outcomes
When implemented, document the durable artifact in `docs/integrations/`, add
the selectable module and validator contracts to configuration documentation,
update the current module and pipeline internals, and provide a maintained
ordered-pipeline example showing NPC extraction followed by interaction
extraction. Future behavior must remain in this roadmap until it exists.
## Non-Goals
This scope does not:
- add occurrence fields to the normalized NPC registry;
- summarize dialogue, combat, or NPC behavior;
- infer disposition, relationships, factions, motives, or persistent state;
- identify player characters or anonymous groups as NPCs;
- add scene participants or duplicate scene-description responsibilities;
- reconcile NPC aliases or perform LLM-assisted semantic deduplication;
- derive interaction records from registry evidence; or
- introduce a DAG, concurrent cross-lane reconciliation, or a new reference
mechanism.

File diff suppressed because it is too large Load Diff

View File

@@ -263,12 +263,25 @@ func TestChangedSemanticSpellCatalogFingerprintCannotResumeRecordedCheckpoint(t
if _, decision := changedLoader.Normalize("spells", spellnormalize.Key, normalizeDependencies); decision.Reused {
t.Fatalf("changed normalize fingerprint decision = %#v, want normalize checkpoint cold miss", decision)
}
changedMapping := replaceCheckpointFingerprintValue(t, fingerprints, extractSpellMappingFingerprintName(), "dnd.spells.extract_mapping.v3")
assertOnlyCheckpointFingerprintChanged(t, fingerprints, changedMapping, extractSpellMappingFingerprintName())
_, mappingLoader, err := checkpointHandlersForRun(settings, Options{}, materialized, changedMapping, []byte("same input"), nil, nil, "", "", true)
if err != nil {
t.Fatal(err)
}
if _, decision := mappingLoader.Source(materialized.Input.Module); decision.Reused {
t.Fatalf("changed mapping policy decision = %#v, want cold miss", decision)
}
}
func normalizeSpellCatalogFingerprintName() string {
return "normalize:spells:" + spellnormalize.Key + ":effective_catalog"
}
func extractSpellMappingFingerprintName() string {
return "extract:spells:" + spells.Key + ":mapping_policy"
}
func replaceCheckpointFingerprintValue(t *testing.T, fingerprints []pipeline.CheckpointFingerprint, name, value string) []pipeline.CheckpointFingerprint {
t.Helper()
changed := append([]pipeline.CheckpointFingerprint(nil), fingerprints...)

View File

@@ -249,6 +249,9 @@ func TestValidateRefValid(t *testing.T) {
if err := ValidateRef(doc, ref); err != nil {
t.Fatalf("ValidateRef() error = %v, want nil", err)
}
if err := NewDocumentIndex(doc).ValidateRef(ref); err != nil {
t.Fatalf("DocumentIndex.ValidateRef() error = %v, want nil", err)
}
}
func TestValidateRefRejectsMalformedReferences(t *testing.T) {
@@ -301,11 +304,54 @@ func TestValidateRefRejectsMalformedReferences(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateRef(validDocument(), tt.ref)
requireErrorFragments(t, err, tt.fragments...)
doc := validDocument()
validators := []struct {
name string
validate func(SourceRef) error
}{
{name: "document", validate: func(ref SourceRef) error { return ValidateRef(doc, ref) }},
{name: "index", validate: NewDocumentIndex(doc).ValidateRef},
}
for _, validator := range validators {
t.Run(validator.name, func(t *testing.T) {
requireErrorFragments(t, validator.validate(tt.ref), tt.fragments...)
})
}
})
}
}
func TestDocumentIndexSnapshotsIdentityAndUnitPositions(t *testing.T) {
doc := &SourceDocument{
ID: "source-1",
Units: []SourceUnit{
{ID: 30},
{ID: 10},
{ID: 30},
},
}
index := NewDocumentIndex(doc)
doc.ID = "changed"
doc.Units[0].ID = 99
if position, ok := index.Position(30); !ok || position != 0 {
t.Fatalf("Position(30) = %d, %t, want 0, true", position, ok)
}
if position, ok := index.Position(10); !ok || position != 1 {
t.Fatalf("Position(10) = %d, %t, want 1, true", position, ok)
}
ref := SourceRef{SourceID: "source-1", StartUnitID: 30, EndUnitID: 10}
if err := index.ValidateRef(ref); err != nil {
t.Fatalf("ValidateRef() error = %v, want nil", err)
}
}
func TestZeroDocumentIndexIsSafe(t *testing.T) {
var index DocumentIndex
if position, ok := index.Position(1); ok || position != 0 {
t.Fatalf("Position(1) = %d, %t, want 0, false", position, ok)
}
requireErrorFragments(t, index.ValidateRef(SourceRef{}), "source document must not be nil")
}
func TestUnitIndex(t *testing.T) {

View File

@@ -5,6 +5,46 @@ import (
"strings"
)
// DocumentIndex is an immutable snapshot of a source document's identity and
// unit positions for repeated source-reference operations.
type DocumentIndex struct {
documentID string
positions map[int]int
hasDocument bool
}
// NewDocumentIndex snapshots doc without retaining or mutating it.
func NewDocumentIndex(doc *SourceDocument) DocumentIndex {
if doc == nil {
return DocumentIndex{}
}
positions := make(map[int]int, len(doc.Units))
for position, unit := range doc.Units {
if _, exists := positions[unit.ID]; !exists {
positions[unit.ID] = position
}
}
return DocumentIndex{
documentID: doc.ID,
positions: positions,
hasDocument: true,
}
}
// Position returns the indexed document position for unitID.
func (i DocumentIndex) Position(unitID int) (int, bool) {
position, ok := i.positions[unitID]
return position, ok
}
// ValidateRef validates ref against the indexed document snapshot.
func (i DocumentIndex) ValidateRef(ref SourceRef) error {
if !i.hasDocument {
return fmt.Errorf("source document must not be nil")
}
return validateRef(i.documentID, i.Position, ref)
}
func ValidateDocument(doc *SourceDocument) error {
if doc == nil {
return fmt.Errorf("source document must not be nil")
@@ -60,6 +100,12 @@ func ValidateRef(doc *SourceDocument, ref SourceRef) error {
if doc == nil {
return fmt.Errorf("source document must not be nil")
}
return validateRef(doc.ID, func(unitID int) (int, bool) {
return UnitIndex(doc, unitID)
}, ref)
}
func validateRef(documentID string, position func(int) (int, bool), ref SourceRef) error {
if isBlank(ref.SourceID) {
return fmt.Errorf("source ref source_id must not be empty")
}
@@ -72,15 +118,15 @@ func ValidateRef(doc *SourceDocument, ref SourceRef) error {
if ref.EndUnitID <= 0 {
return fmt.Errorf("source ref end_unit_id must be positive")
}
if ref.SourceID != doc.ID {
return fmt.Errorf("source ref source_id %q does not match document id %q", ref.SourceID, doc.ID)
if ref.SourceID != documentID {
return fmt.Errorf("source ref source_id %q does not match document id %q", ref.SourceID, documentID)
}
startIndex, ok := UnitIndex(doc, ref.StartUnitID)
startIndex, ok := position(ref.StartUnitID)
if !ok {
return fmt.Errorf("source ref start_unit_id %d was not found", ref.StartUnitID)
}
endIndex, ok := UnitIndex(doc, ref.EndUnitID)
endIndex, ok := position(ref.EndUnitID)
if !ok {
return fmt.Errorf("source ref end_unit_id %d was not found", ref.EndUnitID)
}

View File

@@ -156,28 +156,25 @@ func planFromResponse(doc *source.SourceDocument, response chunkResponse) (sourc
return source.ChunkPlan{}, fmt.Errorf("scenes must not be empty")
}
unitIndexes := make(map[int]int, len(doc.Units))
for i, unit := range doc.Units {
unitIndexes[unit.ID] = i
}
index := source.NewDocumentIndex(doc)
ranges := make([]source.ChunkRange, 0, len(response.Scenes))
previousEnd := -1
for i, scene := range response.Scenes {
startUnitID, err := shared.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID)
startUnitID, err := shared.ResolveUnitID(index, "start_unit_id", scene.StartUnitID)
if err != nil {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
}
endUnitID, err := shared.ResolveUnitID(doc, "end_unit_id", scene.EndUnitID)
endUnitID, err := shared.ResolveUnitID(index, "end_unit_id", scene.EndUnitID)
if err != nil {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
}
startIndex, ok := unitIndexes[startUnitID]
startIndex, ok := index.Position(startUnitID)
if !ok {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d was not found", i, startUnitID)
}
endIndex, ok := unitIndexes[endUnitID]
endIndex, ok := index.Position(endUnitID)
if !ok {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] end_unit_id %d was not found", i, endUnitID)
}

View File

@@ -0,0 +1,38 @@
// Package candidatejson provides strict JSON mechanics for D&D artifact
// candidates before artifact-specific validation.
package candidatejson
import (
"bytes"
"encoding/json"
"fmt"
"io"
)
// EncodeCandidate encodes a typed candidate with an artifact-specific error
// label.
func EncodeCandidate[T any](label string, value T) ([]byte, error) {
content, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("encode %s: %w", label, err)
}
return content, nil
}
// DecodeCandidate decodes exactly one typed JSON value while rejecting unknown
// fields and trailing values with an artifact-specific error label.
func DecodeCandidate[T any](label string, content []byte) (T, error) {
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields()
var value T
if err := decoder.Decode(&value); err != nil {
var zero T
return zero, fmt.Errorf("decode %s: %w", label, err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
var zero T
return zero, fmt.Errorf("decode %s: multiple JSON values", label)
}
return value, nil
}

View File

@@ -0,0 +1,47 @@
package candidatejson
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
type testCandidate struct {
Items []string `json:"items"`
}
func TestCandidateJSON(t *testing.T) {
input := testCandidate{Items: []string{"one", "two"}}
content, err := EncodeCandidate("test candidate", input)
if err != nil {
t.Fatalf("EncodeCandidate() error = %v", err)
}
if !json.Valid(content) {
t.Fatalf("EncodeCandidate() = %q, want JSON", content)
}
for _, test := range []struct {
name string
content []byte
want testCandidate
wantErr string
}{
{name: "typed round trip", content: content, want: input},
{name: "unknown field", content: []byte(`{"items":[],"unexpected":true}`), wantErr: "unknown field"},
{name: "malformed JSON", content: []byte(`{"items":`), wantErr: "decode test candidate"},
{name: "trailing value", content: []byte(`{"items":[]} {}`), wantErr: "multiple JSON values"},
} {
t.Run(test.name, func(t *testing.T) {
got, err := DecodeCandidate[testCandidate]("test candidate", test.content)
if test.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("DecodeCandidate() error = %v, want %q", err, test.wantErr)
}
return
}
if err != nil || !reflect.DeepEqual(got, test.want) {
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", got, err, test.want)
}
})
}
}

View File

@@ -1,15 +1,13 @@
package combatturns
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"io"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
)
const (
@@ -59,11 +57,7 @@ func (c *Codec) Encode(value dnd.CombatTurnList) ([]byte, error) {
// EncodeCandidate provides the durable representation before typed validators
// have approved a value.
func (c *Codec) EncodeCandidate(value dnd.CombatTurnList) ([]byte, error) {
content, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("encode dnd combat turn list: %w", err)
}
return content, nil
return candidatejson.EncodeCandidate("dnd combat turn list", value)
}
func (c *Codec) Decode(content []byte) (dnd.CombatTurnList, error) {
@@ -80,17 +74,7 @@ func (c *Codec) Decode(content []byte) (dnd.CombatTurnList, error) {
// DecodeCandidate reads one strict durable JSON value before semantic
// validators have approved it.
func (c *Codec) DecodeCandidate(content []byte) (dnd.CombatTurnList, error) {
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields()
var value dnd.CombatTurnList
if err := decoder.Decode(&value); err != nil {
return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: multiple JSON values")
}
return value, nil
return candidatejson.DecodeCandidate[dnd.CombatTurnList]("dnd combat turn list", content)
}
func validate(value dnd.CombatTurnList) error {

View File

@@ -2,15 +2,13 @@
package npcinteractions
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"io"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
)
const (
@@ -60,11 +58,7 @@ func (c *Codec) Encode(value dnd.NPCInteractionList) ([]byte, error) {
// EncodeCandidate provides the durable representation before semantic
// validators have approved a value.
func (c *Codec) EncodeCandidate(value dnd.NPCInteractionList) ([]byte, error) {
content, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("encode dnd npc interaction list: %w", err)
}
return content, nil
return candidatejson.EncodeCandidate("dnd npc interaction list", value)
}
func (c *Codec) Decode(content []byte) (dnd.NPCInteractionList, error) {
@@ -81,17 +75,7 @@ func (c *Codec) Decode(content []byte) (dnd.NPCInteractionList, error) {
// DecodeCandidate reads one strict durable JSON value before semantic
// validators have approved it.
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCInteractionList, error) {
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields()
var value dnd.NPCInteractionList
if err := decoder.Decode(&value); err != nil {
return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: multiple JSON values")
}
return value, nil
return candidatejson.DecodeCandidate[dnd.NPCInteractionList]("dnd npc interaction list", content)
}
func validate(value dnd.NPCInteractionList) error {

View File

@@ -1,15 +1,13 @@
package npcs
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"io"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
@@ -60,11 +58,7 @@ func (c *Codec) Encode(value dnd.NPCList) ([]byte, error) {
// EncodeCandidate provides the durable representation before semantic
// validators have approved a value.
func (c *Codec) EncodeCandidate(value dnd.NPCList) ([]byte, error) {
content, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("encode dnd npc list: %w", err)
}
return content, nil
return candidatejson.EncodeCandidate("dnd npc list", value)
}
func (c *Codec) Decode(content []byte) (dnd.NPCList, error) {
@@ -81,17 +75,7 @@ func (c *Codec) Decode(content []byte) (dnd.NPCList, error) {
// DecodeCandidate reads one strict durable JSON value before semantic
// validators have approved it.
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCList, error) {
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields()
var value dnd.NPCList
if err := decoder.Decode(&value); err != nil {
return dnd.NPCList{}, fmt.Errorf("decode dnd npc list: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return dnd.NPCList{}, fmt.Errorf("decode dnd npc list: multiple JSON values")
}
return value, nil
return candidatejson.DecodeCandidate[dnd.NPCList]("dnd npc list", content)
}
func validate(value dnd.NPCList) error {

View File

@@ -1,15 +1,13 @@
package scenedescriptions
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"io"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
)
const (
@@ -59,11 +57,7 @@ func (c *Codec) Encode(value dnd.SceneDescriptionList) ([]byte, error) {
// EncodeCandidate provides the durable representation before typed validators
// have approved a value.
func (c *Codec) EncodeCandidate(value dnd.SceneDescriptionList) ([]byte, error) {
content, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("encode dnd scene description list: %w", err)
}
return content, nil
return candidatejson.EncodeCandidate("dnd scene description list", value)
}
func (c *Codec) Decode(content []byte) (dnd.SceneDescriptionList, error) {
@@ -80,17 +74,7 @@ func (c *Codec) Decode(content []byte) (dnd.SceneDescriptionList, error) {
// DecodeCandidate reads one strict durable JSON value before typed validators
// have approved it.
func (c *Codec) DecodeCandidate(content []byte) (dnd.SceneDescriptionList, error) {
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields()
var value dnd.SceneDescriptionList
if err := decoder.Decode(&value); err != nil {
return dnd.SceneDescriptionList{}, fmt.Errorf("decode dnd scene description list: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return dnd.SceneDescriptionList{}, fmt.Errorf("decode dnd scene description list: multiple JSON values")
}
return value, nil
return candidatejson.DecodeCandidate[dnd.SceneDescriptionList]("dnd scene description list", content)
}
func validate(value dnd.SceneDescriptionList) error {

View File

@@ -1,15 +1,13 @@
package spells
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"io"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
)
const (
@@ -54,11 +52,7 @@ func (c *Codec) Encode(value dnd.SpellList) ([]byte, error) {
// EncodeCandidate provides the same stable representation before typed
// validators have approved a value.
func (c *Codec) EncodeCandidate(value dnd.SpellList) ([]byte, error) {
content, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("encode dnd spell list: %w", err)
}
return content, nil
return candidatejson.EncodeCandidate("dnd spell list", value)
}
func (c *Codec) Decode(content []byte) (dnd.SpellList, error) {
@@ -75,17 +69,7 @@ func (c *Codec) Decode(content []byte) (dnd.SpellList, error) {
// DecodeCandidate reads the durable representation before semantic validators
// have approved it.
func (c *Codec) DecodeCandidate(content []byte) (dnd.SpellList, error) {
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields()
var value dnd.SpellList
if err := decoder.Decode(&value); err != nil {
return dnd.SpellList{}, fmt.Errorf("decode dnd spell list: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return dnd.SpellList{}, fmt.Errorf("decode dnd spell list: multiple JSON values")
}
return value, nil
return candidatejson.DecodeCandidate[dnd.SpellList]("dnd spell list", content)
}
func validate(value dnd.SpellList) error {

View File

@@ -5,89 +5,49 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
func canonicalizeResponse(response *extractionResponse, doc *source.SourceDocument) {
type orderedCombatTurnResponse struct {
value combatTurnResponse
earliest int
hasEvidence bool
}
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string) {
if response == nil {
return
}
ordered := make([]orderedCombatTurnResponse, len(response.CombatTurns))
for index := range response.CombatTurns {
canonicalizeCombatTurn(&response.CombatTurns[index])
earliest, hasEvidence := canonicalizeCombatTurn(&response.CombatTurns[index], order, sourceID)
ordered[index] = orderedCombatTurnResponse{
value: response.CombatTurns[index],
earliest: earliest,
hasEvidence: hasEvidence,
}
sort.SliceStable(response.CombatTurns, func(i, j int) bool {
left, leftOK := earliestSourcePosition(doc, response.CombatTurns[i])
right, rightOK := earliestSourcePosition(doc, response.CombatTurns[j])
if leftOK != rightOK {
return leftOK
}
if !leftOK {
sort.SliceStable(ordered, func(i, j int) bool {
if ordered[i].hasEvidence != ordered[j].hasEvidence {
return ordered[i].hasEvidence
}
if !ordered[i].hasEvidence {
return false
}
return left < right
return ordered[i].earliest < ordered[j].earliest
})
for index := range ordered {
response.CombatTurns[index] = ordered[index].value
}
}
func canonicalizeCombatTurn(turn *combatTurnResponse) {
func canonicalizeCombatTurn(turn *combatTurnResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
if turn == nil {
return
}
sort.SliceStable(turn.SourceRefs, func(i, j int) bool {
left := turn.SourceRefs[i]
right := turn.SourceRefs[j]
if unitSortValue(left.StartUnitID) != unitSortValue(right.StartUnitID) {
return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID)
}
return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID)
})
turn.SourceRefs = dedupeSourceRefs(turn.SourceRefs)
}
func dedupeSourceRefs(refs []combatSourceRefResponse) []combatSourceRefResponse {
if len(refs) < 2 {
return refs
}
out := refs[:0]
var previous combatSourceRefResponse
for index, ref := range refs {
if index > 0 && sameSourceRef(previous, ref) {
continue
}
out = append(out, ref)
previous = ref
}
return out
}
func sameSourceRef(left combatSourceRefResponse, right combatSourceRefResponse) bool {
return left == right
}
func earliestSourcePosition(doc *source.SourceDocument, turn combatTurnResponse) (int, bool) {
if doc == nil {
return 0, false
}
earliest := 0
found := false
for _, ref := range turn.SourceRefs {
candidate := source.SourceRef{SourceID: doc.ID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
if err := source.ValidateRef(doc, candidate); err != nil {
continue
}
index, ok := source.UnitIndex(doc, candidate.StartUnitID)
if !ok || (found && index >= earliest) {
continue
}
earliest = index
found = true
}
return earliest, found
}
func unitSortValue(value int) int {
if value <= 0 {
return int(^uint(0) >> 1)
}
return value
refs := order.Canonicalize(canonicalSourceRefs(turn.SourceRefs, sourceID))
turn.SourceRefs = combatResponseRefs(refs)
return order.EarliestValid(refs)
}
func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.CombatTurnList {
@@ -115,3 +75,14 @@ func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []sour
}
return out
}
func combatResponseRefs(refs []source.SourceRef) []combatSourceRefResponse {
if refs == nil {
return nil
}
values := make([]combatSourceRefResponse, len(refs))
for index, ref := range refs {
values[index] = combatSourceRefResponse{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
}
return values
}

View File

@@ -14,8 +14,7 @@ import (
const (
Key = "dnd/combat-turns"
ArtifactType = "dnd.combat_turn"
mappingPolicy = "dnd.combat_turns.extract_mapping.v1"
mappingPolicy = "dnd.combat_turns.extract_mapping.v2"
)
const (
@@ -144,25 +143,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
if e.llm == nil {
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("LLM client must not be nil")
}
if ctx == nil {
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("context must not be nil")
}
if err := ctx.Err(); err != nil {
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("context error before extraction: %w", err)
}
if req.Source == nil {
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("source must not be nil")
}
if req.Chunk == nil {
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("chunk must not be nil")
}
if len(req.Chunk.Units) == 0 {
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
}
sourceInput, err := shared.ChunkPromptMaterial(req)
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
if err != nil {
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("%w", err)
}
order := shared.NewSourceRefOrder(req.Source)
npcRegistry, err := e.npcResolver.Resolve(req.References)
if err != nil {
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("resolve NPC registry: %w", err)
@@ -181,7 +166,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
}, &response); err != nil {
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("complete structured output: %w", err)
}
canonicalizeResponse(&response, req.Source)
canonicalizeResponse(&response, order, req.Source.ID)
return contracts.TypedExtractionResult[dnd.CombatTurnList]{Value: canonicalCombatTurnList(response, req.Source.ID)}, nil
}

View File

@@ -82,6 +82,43 @@ func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
}
}
func TestExtractUsesDocumentOrderForReferencesAndTurns(t *testing.T) {
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{
{Actor: "Later", TurnKind: "turn", SourceRefs: []combatSourceRefResponse{{StartUnitID: 10, EndUnitID: 10}}},
{Actor: "First", TurnKind: "reaction", SourceRefs: []combatSourceRefResponse{
{StartUnitID: 10, EndUnitID: 10},
{StartUnitID: 30, EndUnitID: 30},
{StartUnitID: 30, EndUnitID: 30},
{StartUnitID: 999, EndUnitID: 0},
}},
{Actor: "Second", TurnKind: "other", SourceRefs: []combatSourceRefResponse{{StartUnitID: 30, EndUnitID: 30}}},
}}}
req := extractionRequest()
req.Source.Units = []source.SourceUnit{{ID: 30}, {ID: 10}}
req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...)
req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 10}
result, err := newExtractor(t, client).Extract(context.Background(), req)
if err != nil {
t.Fatalf("Extract() error = %v", err)
}
if got := []string{result.Value.CombatTurns[0].Actor, result.Value.CombatTurns[1].Actor, result.Value.CombatTurns[2].Actor}; !reflect.DeepEqual(got, []string{"First", "Second", "Later"}) {
t.Fatalf("turn order = %#v, want document chronology with stable equal-evidence ties", got)
}
refs := result.Value.CombatTurns[0].SourceRefs
if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) {
t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs)
}
refs[0].StartUnitID = 777
for _, turn := range client.response.CombatTurns {
for _, ref := range turn.SourceRefs {
if ref.StartUnitID == 777 {
t.Fatal("result source references alias the model response")
}
}
}
}
func TestExtractPassesReferencesAndNPCGroundingWithoutUsingItAsEvidence(t *testing.T) {
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{}}}
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
@@ -185,9 +222,8 @@ func TestNewRejectsMalformedNPCRegistryBeforeLLMCallWithoutContent(t *testing.T)
func TestExtractRejectsInvalidRequestsAndWrapsProviderFailures(t *testing.T) {
validReq := extractionRequest()
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
validExtractor := newExtractor(t, &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{}}})
var nilExtractor *Extractor
tests := []struct {
name string
extractor *Extractor
@@ -195,13 +231,9 @@ func TestExtractRejectsInvalidRequestsAndWrapsProviderFailures(t *testing.T) {
req contracts.TypedExtractionRequest
want string
}{
{name: "nil extractor", ctx: context.Background(), req: validReq, want: "extractor"},
{name: "nil context", extractor: validExtractor, req: validReq, want: "context"},
{name: "canceled context", extractor: validExtractor, ctx: canceledCtx, req: validReq, want: "context"},
{name: "nil source", extractor: validExtractor, ctx: context.Background(), req: contracts.TypedExtractionRequest{Chunk: validReq.Chunk}, want: "source"},
{name: "nil chunk", extractor: validExtractor, ctx: context.Background(), req: contracts.TypedExtractionRequest{Source: validReq.Source}, want: "chunk"},
{name: "empty chunk units", extractor: validExtractor, ctx: context.Background(), req: emptyChunkRequest(validReq), want: "units"},
{name: "source input mismatch", extractor: validExtractor, ctx: context.Background(), req: mismatchedSourceInputRequest(validReq), want: "must match chunk"},
{name: "nil extractor", extractor: nilExtractor, ctx: context.Background(), req: validReq, want: "extractor"},
{name: "nil LLM client", extractor: &Extractor{}, ctx: context.Background(), req: validReq, want: "LLM client"},
{name: "wrapped preflight failure", extractor: validExtractor, ctx: context.Background(), req: mismatchedSourceInputRequest(validReq), want: "must match chunk"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -302,11 +334,6 @@ func combatSourceDocument() *source.SourceDocument {
}
}
func emptyChunkRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
req.Chunk = &source.Chunk{ID: req.Chunk.ID, SourceID: req.Chunk.SourceID, Index: req.Chunk.Index}
return req
}
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"other":true}`), "sha256:other", "")
return req

View File

@@ -62,16 +62,48 @@ func TestScriptoriumPromptPreparesRequiredInputs(t *testing.T) {
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_combat_turns_llm.v1.json" {
t.Fatalf("prepared prompt = %#v, want combat prompt identity and schema", prepared)
}
for _, want := range []string{transcript, "Dana: Mira", "Mira: ranger", "Greencloak: title", `{"npcs":[]}`} {
found := false
for _, message := range prepared.Messages {
if strings.Contains(message.Content, want) {
found = true
break
for index, want := range []struct {
role string
cached bool
marker string
}{
{role: "system", marker: "Dungeons & Dragons gameplay transcripts"},
{role: "user", marker: "Transcript units are the only evidence"},
{role: "user", cached: true, marker: "most specific supported in-world"},
{role: "user", cached: true},
{role: "user", cached: true},
{role: "user", marker: "combat-turn artifacts"},
{role: "user", cached: true, marker: "turn_kind"},
{role: "user"},
} {
if index >= len(prepared.Messages) {
t.Fatalf("prepared prompt has %d messages, want at least %d", len(prepared.Messages), index+1)
}
message := prepared.Messages[index]
if message.Role != want.role {
t.Errorf("message %d role = %q, want %q", index, message.Role, want.role)
}
if want.cached {
if message.CacheControl == nil || message.CacheControl.Type != scriptorium.CacheControlEphemeral {
t.Errorf("message %d cache control = %#v, want ephemeral", index, message.CacheControl)
}
} else if message.CacheControl != nil {
t.Errorf("message %d cache control = %#v, want nil", index, message.CacheControl)
}
if want.marker != "" && !strings.Contains(message.Content, want.marker) {
t.Errorf("message %d content does not contain purpose marker %q", index, want.marker)
}
}
if !found {
t.Fatalf("prepared prompt did not render required input %q", want)
if len(prepared.Messages) != 8 {
t.Fatalf("prepared prompt has %d messages, want 8", len(prepared.Messages))
}
if references := prepared.Messages[3].Content; !strings.Contains(references, "Dana: Mira") || !strings.Contains(references, "Mira: ranger") || !strings.Contains(references, "Greencloak: title") {
t.Fatalf("campaign references message = %q, want rendered reference inputs", references)
}
if registry := prepared.Messages[4].Content; !strings.Contains(registry, `{"npcs":[]}`) {
t.Fatalf("NPC registry message = %q, want registry input", registry)
}
if final := prepared.Messages[7].Content; !strings.Contains(final, transcript) {
t.Fatalf("final message = %q, want transcript", final)
}
}

View File

@@ -5,85 +5,49 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
func canonicalizeResponse(response *extractionResponse, doc *source.SourceDocument) {
type orderedInteractionResponse struct {
value interactionResponse
earliest int
hasEvidence bool
}
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string) {
if response == nil {
return
}
ordered := make([]orderedInteractionResponse, len(response.Interactions))
for index := range response.Interactions {
canonicalizeInteraction(&response.Interactions[index])
earliest, hasEvidence := canonicalizeInteraction(&response.Interactions[index], order, sourceID)
ordered[index] = orderedInteractionResponse{
value: response.Interactions[index],
earliest: earliest,
hasEvidence: hasEvidence,
}
sort.SliceStable(response.Interactions, func(i, j int) bool {
left, leftOK := earliestSourcePosition(doc, response.Interactions[i])
right, rightOK := earliestSourcePosition(doc, response.Interactions[j])
if leftOK != rightOK {
return leftOK
}
if !leftOK {
sort.SliceStable(ordered, func(i, j int) bool {
if ordered[i].hasEvidence != ordered[j].hasEvidence {
return ordered[i].hasEvidence
}
if !ordered[i].hasEvidence {
return false
}
return left < right
return ordered[i].earliest < ordered[j].earliest
})
for index := range ordered {
response.Interactions[index] = ordered[index].value
}
}
func canonicalizeInteraction(interaction *interactionResponse) {
func canonicalizeInteraction(interaction *interactionResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
if interaction == nil {
return
}
sort.SliceStable(interaction.SourceRefs, func(i, j int) bool {
left := interaction.SourceRefs[i]
right := interaction.SourceRefs[j]
if unitSortValue(left.StartUnitID) != unitSortValue(right.StartUnitID) {
return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID)
}
return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID)
})
interaction.SourceRefs = dedupeSourceRefs(interaction.SourceRefs)
}
func dedupeSourceRefs(refs []interactionSourceRefResponse) []interactionSourceRefResponse {
if len(refs) < 2 {
return refs
}
out := refs[:0]
var previous interactionSourceRefResponse
for index, ref := range refs {
if index > 0 && previous == ref {
continue
}
out = append(out, ref)
previous = ref
}
return out
}
func earliestSourcePosition(doc *source.SourceDocument, interaction interactionResponse) (int, bool) {
if doc == nil {
return 0, false
}
found := false
earliest := 0
for _, ref := range interaction.SourceRefs {
candidate := source.SourceRef{SourceID: doc.ID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
if err := source.ValidateRef(doc, candidate); err != nil {
continue
}
index, ok := source.UnitIndex(doc, candidate.StartUnitID)
if !ok || (found && index >= earliest) {
continue
}
earliest = index
found = true
}
return earliest, found
}
func unitSortValue(value int) int {
if value <= 0 {
return int(^uint(0) >> 1)
}
return value
refs := order.Canonicalize(canonicalSourceRefs(interaction.SourceRefs, sourceID))
interaction.SourceRefs = interactionResponseRefs(refs)
return order.EarliestValid(refs)
}
func canonicalInteractionList(response extractionResponse, sourceID string) dnd.NPCInteractionList {
@@ -111,3 +75,14 @@ func canonicalSourceRefs(refs []interactionSourceRefResponse, sourceID string) [
}
return out
}
func interactionResponseRefs(refs []source.SourceRef) []interactionSourceRefResponse {
if refs == nil {
return nil
}
values := make([]interactionSourceRefResponse, len(refs))
for index, ref := range refs {
values[index] = interactionSourceRefResponse{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
}
return values
}

View File

@@ -14,7 +14,7 @@ import (
const (
Key = "dnd/npc-interactions"
mappingPolicy = "dnd.npc_interactions.extract_mapping.v1"
mappingPolicy = "dnd.npc_interactions.extract_mapping.v2"
)
const (
@@ -143,25 +143,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
if e.llm == nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("LLM client must not be nil")
}
if ctx == nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("context must not be nil")
}
if err := ctx.Err(); err != nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("context error before extraction: %w", err)
}
if req.Source == nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("source must not be nil")
}
if req.Chunk == nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("chunk must not be nil")
}
if len(req.Chunk.Units) == 0 {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
}
sourceInput, err := shared.ChunkPromptMaterial(req)
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
if err != nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("%w", err)
}
order := shared.NewSourceRefOrder(req.Source)
npcRegistry, err := e.npcResolver.Resolve(req.References)
if err != nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("resolve NPC registry: %w", err)
@@ -183,7 +169,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
}, &response); err != nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("complete structured output: %w", err)
}
canonicalizeResponse(&response, req.Source)
canonicalizeResponse(&response, order, req.Source.ID)
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{Value: canonicalInteractionList(response, req.Source.ID)}, nil
}

View File

@@ -59,6 +59,45 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
}
}
func TestExtractUsesDocumentOrderForReferencesAndInteractions(t *testing.T) {
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{
{Name: "Later", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)},
{Name: "First", Kind: "mentioned", SourceRefs: []interactionSourceRefResponse{
{StartUnitID: 10, EndUnitID: 10},
{StartUnitID: 30, EndUnitID: 30},
{StartUnitID: 30, EndUnitID: 30},
{StartUnitID: 999, EndUnitID: 0},
}},
{Name: "Second", Kind: "other", SourceRefs: interactionRefs(30, 30)},
}}}
references := requiredRegistryReferences(t, "Later", "First", "Second")
req := extractionRequest()
req.References = references
req.Source.Units = []source.SourceUnit{{ID: 30}, {ID: 10}}
req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...)
req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 10}
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
if err != nil {
t.Fatalf("Extract() error = %v", err)
}
if got := interactionNames(result.Value); !reflect.DeepEqual(got, []string{"First", "Second", "Later"}) {
t.Fatalf("interaction order = %#v, want document chronology with stable equal-evidence ties", got)
}
refs := result.Value.Interactions[0].SourceRefs
if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) {
t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs)
}
refs[0].StartUnitID = 777
for _, interaction := range client.response.Interactions {
for _, ref := range interaction.SourceRefs {
if ref.StartUnitID == 777 {
t.Fatal("result source references alias the model response")
}
}
}
}
func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets(t *testing.T) {
if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") {
t.Fatalf("New(nil) error = %v", err)
@@ -165,24 +204,21 @@ func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) {
references := requiredRegistryReferences(t, "Mira Thorn")
valid := extractionRequest()
valid.References = references
canceled, cancel := context.WithCancel(context.Background())
cancel()
extractor := newExtractor(t, &fakeInteractionsLLMClient{}, references)
var nilExtractor *Extractor
for _, test := range []struct {
name string
extractor *Extractor
ctx context.Context
req contracts.TypedExtractionRequest
want string
}{
{"nil context", nil, valid, "context"},
{"canceled context", canceled, valid, "context"},
{"nil source", context.Background(), contracts.TypedExtractionRequest{Chunk: valid.Chunk, References: references}, "source"},
{"nil chunk", context.Background(), contracts.TypedExtractionRequest{Source: valid.Source, References: references}, "chunk"},
{"empty chunk", context.Background(), emptyChunkRequest(valid), "units"},
{"source input mismatch", context.Background(), mismatchedSourceInputRequest(valid), "must match chunk"},
{"nil extractor", nilExtractor, context.Background(), valid, "extractor"},
{"nil LLM client", &Extractor{}, context.Background(), valid, "LLM client"},
{"wrapped preflight failure", extractor, context.Background(), mismatchedSourceInputRequest(valid), "must match chunk"},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := extractor.Extract(test.ctx, test.req); err == nil || !strings.Contains(err.Error(), test.want) {
if _, err := test.extractor.Extract(test.ctx, test.req); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Extract() error = %v, want %q", err, test.want)
}
})
@@ -322,11 +358,6 @@ func interactionKinds(value dnd.NPCInteractionList) []dnd.NPCInteractionKind {
return kinds
}
func emptyChunkRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
req.Chunk = &source.Chunk{ID: req.Chunk.ID, SourceID: req.Chunk.SourceID, Index: req.Chunk.Index}
return req
}
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "")
return req

View File

@@ -51,22 +51,49 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) {
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npc_interactions_llm.v1.json" {
t.Fatalf("prepared prompt = %#v", prepared)
}
for _, want := range []string{
"Mira Thorn", "mentioned", "combat_opponent", "Registry content is context, not event evidence", transcript,
for index, want := range []struct {
role string
cached bool
marker string
}{
{role: "system", marker: "Dungeons & Dragons gameplay transcripts"},
{role: "user", marker: "Transcript units are the only evidence"},
{role: "user", cached: true, marker: "most specific supported in-world"},
{role: "user", cached: true},
{role: "user", cached: true},
{role: "user", marker: "interaction occurrences"},
{role: "user", cached: true, marker: "Use exactly one kind per occurrence"},
{role: "user"},
} {
found := false
for _, message := range prepared.Messages {
if strings.Contains(message.Content, want) {
found = true
break
if index >= len(prepared.Messages) {
t.Fatalf("prepared prompt has %d messages, want at least %d", len(prepared.Messages), index+1)
}
message := prepared.Messages[index]
if message.Role != want.role {
t.Errorf("message %d role = %q, want %q", index, message.Role, want.role)
}
if want.cached {
if message.CacheControl == nil || message.CacheControl.Type != scriptorium.CacheControlEphemeral {
t.Errorf("message %d cache control = %#v, want ephemeral", index, message.CacheControl)
}
} else if message.CacheControl != nil {
t.Errorf("message %d cache control = %#v, want nil", index, message.CacheControl)
}
if want.marker != "" && !strings.Contains(message.Content, want.marker) {
t.Errorf("message %d content does not contain purpose marker %q", index, want.marker)
}
}
if !found {
t.Fatalf("prepared prompt did not include %q", want)
if len(prepared.Messages) != 8 {
t.Fatalf("prepared prompt has %d messages, want 8", len(prepared.Messages))
}
if references := prepared.Messages[3].Content; !strings.Contains(references, "Dana: Mira") || !strings.Contains(references, "Mira: ranger") || !strings.Contains(references, "Greencloak: title") {
t.Fatalf("campaign references message = %q, want rendered reference inputs", references)
}
if last := prepared.Messages[len(prepared.Messages)-1]; !strings.Contains(last.Content, transcript) {
t.Fatalf("last prompt message = %q, want transcript", last.Content)
if registry := prepared.Messages[4].Content; !strings.Contains(registry, `{"npcs":[{"name":"Mira Thorn"}]}`) {
t.Fatalf("NPC registry message = %q, want names-only registry input", registry)
}
if final := prepared.Messages[7].Content; !strings.Contains(final, transcript) {
t.Fatalf("final message = %q, want transcript", final)
}
}

View File

@@ -6,89 +6,49 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
func canonicalizeResponse(response *extractionResponse, doc *source.SourceDocument) {
type orderedNPCResponse struct {
value npcResponse
earliest int
hasEvidence bool
}
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string) {
if response == nil {
return
}
ordered := make([]orderedNPCResponse, len(response.NPCs))
for index := range response.NPCs {
canonicalizeNPC(&response.NPCs[index])
earliest, hasEvidence := canonicalizeNPC(&response.NPCs[index], order, sourceID)
ordered[index] = orderedNPCResponse{
value: response.NPCs[index],
earliest: earliest,
hasEvidence: hasEvidence,
}
sort.SliceStable(response.NPCs, func(i, j int) bool {
left, leftOK := earliestSourceIndex(doc, response.NPCs[i])
right, rightOK := earliestSourceIndex(doc, response.NPCs[j])
if leftOK != rightOK {
return leftOK
}
if !leftOK {
sort.SliceStable(ordered, func(i, j int) bool {
if ordered[i].hasEvidence != ordered[j].hasEvidence {
return ordered[i].hasEvidence
}
if !ordered[i].hasEvidence {
return false
}
return left < right
return ordered[i].earliest < ordered[j].earliest
})
for index := range ordered {
response.NPCs[index] = ordered[index].value
}
}
func canonicalizeNPC(npc *npcResponse) {
func canonicalizeNPC(npc *npcResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
if npc == nil {
return
return 0, false
}
sort.SliceStable(npc.SourceRefs, func(i, j int) bool {
left := npc.SourceRefs[i]
right := npc.SourceRefs[j]
if unitSortValue(left.StartUnitID) != unitSortValue(right.StartUnitID) {
return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID)
}
return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID)
})
npc.SourceRefs = dedupeSourceRefs(npc.SourceRefs)
}
func dedupeSourceRefs(refs []npcSourceRefResponse) []npcSourceRefResponse {
if len(refs) < 2 {
return refs
}
out := refs[:0]
var previous npcSourceRefResponse
for index, ref := range refs {
if index > 0 && sameSourceRef(previous, ref) {
continue
}
out = append(out, ref)
previous = ref
}
return out
}
func sameSourceRef(left npcSourceRefResponse, right npcSourceRefResponse) bool {
return left.StartUnitID == right.StartUnitID && left.EndUnitID == right.EndUnitID
}
func earliestSourceIndex(doc *source.SourceDocument, npc npcResponse) (int, bool) {
earliest := 0
found := false
for _, ref := range npc.SourceRefs {
start := ref.StartUnitID
end := ref.EndUnitID
if start > 0 && end > 0 {
startIndex, startOK := source.UnitIndex(doc, start)
endIndex, endOK := source.UnitIndex(doc, end)
if !startOK || !endOK || startIndex > endIndex {
continue
}
if !found || startIndex < earliest {
earliest = startIndex
found = true
}
}
}
return earliest, found
}
func unitSortValue(value int) int {
if value <= 0 {
return int(^uint(0) >> 1)
}
return value
refs := order.Canonicalize(canonicalSourceRefs(npc.SourceRefs, sourceID))
npc.SourceRefs = npcResponseRefs(refs)
return order.EarliestValid(refs)
}
func canonicalNPCList(response extractionResponse, sourceID string) dnd.NPCList {
@@ -120,3 +80,14 @@ func canonicalSourceRefs(values []npcSourceRefResponse, sourceID string) []sourc
}
return out
}
func npcResponseRefs(values []source.SourceRef) []npcSourceRefResponse {
if values == nil {
return nil
}
out := make([]npcSourceRefResponse, len(values))
for index, value := range values {
out[index] = npcSourceRefResponse{StartUnitID: value.StartUnitID, EndUnitID: value.EndUnitID}
}
return out
}

View File

@@ -13,6 +13,8 @@ import (
const Key = "dnd/npcs"
const mappingPolicy = "dnd.npcs.extract_mapping.v2"
var requiredCapabilities = []string{
"chunks",
"source.transcript",
@@ -85,6 +87,7 @@ func (e *Extractor) ManifestMetadata() map[string]any {
"response_schema_version": SchemaVersion,
"response_schema_sha256": e.responseSchemaSHA,
"identity_policy": identity.Policy,
"mapping_policy": mappingPolicy,
}
}
@@ -96,6 +99,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
{Name: "prompt", Value: e.promptSHA},
{Name: "response_schema", Value: e.responseSchemaSHA},
{Name: "identity_policy", Value: identity.Policy},
{Name: "mapping_policy", Value: mappingPolicy},
}
}
@@ -106,25 +110,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
if e.llm == nil {
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("LLM client must not be nil")
}
if ctx == nil {
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("context must not be nil")
}
if err := ctx.Err(); err != nil {
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("context error before extraction: %w", err)
}
if req.Source == nil {
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("source must not be nil")
}
if req.Chunk == nil {
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("chunk must not be nil")
}
if len(req.Chunk.Units) == 0 {
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
}
sourceInput, err := shared.ChunkPromptMaterial(req)
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
if err != nil {
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("%w", err)
}
order := shared.NewSourceRefOrder(req.Source)
var response extractionResponse
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
@@ -137,7 +127,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
}, &response); err != nil {
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("complete structured output: %w", err)
}
canonicalizeResponse(&response, req.Source)
canonicalizeResponse(&response, order, req.Source.ID)
return contracts.TypedExtractionResult[dnd.NPCList]{Value: canonicalNPCList(response, req.Source.ID)}, nil
}

View File

@@ -84,6 +84,43 @@ func TestExtractOrdersNPCsBySourcePositionRatherThanUnitID(t *testing.T) {
}
}
func TestExtractUsesDocumentOrderForNPCReferencesAndStableTies(t *testing.T) {
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
{Name: "Later", SourceRefs: responseSourceRefs(10, 10)},
{Name: "First", SourceRefs: []npcSourceRefResponse{
{StartUnitID: 10, EndUnitID: 10},
{StartUnitID: 30, EndUnitID: 30},
{StartUnitID: 30, EndUnitID: 30},
{StartUnitID: 999, EndUnitID: 0},
}},
{Name: "Second", SourceRefs: responseSourceRefs(30, 30)},
}}}
req := extractionRequest()
req.Source.Units = []source.SourceUnit{{ID: 30}, {ID: 10}}
req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...)
req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 10}
result, err := newExtractor(t, client).Extract(context.Background(), req)
if err != nil {
t.Fatalf("Extract() error = %v", err)
}
if got := []string{result.Value.NPCs[0].Name, result.Value.NPCs[1].Name, result.Value.NPCs[2].Name}; !reflect.DeepEqual(got, []string{"First", "Second", "Later"}) {
t.Fatalf("NPC order = %#v, want document chronology with stable equal-evidence ties", got)
}
refs := result.Value.NPCs[0].SourceRefs
if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) {
t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs)
}
refs[0].StartUnitID = 777
for _, npc := range client.response.NPCs {
for _, ref := range npc.SourceRefs {
if ref.StartUnitID == 777 {
t.Fatal("result source references alias the model response")
}
}
}
}
func TestExtractPassesCampaignReferencesAsPromptInputs(t *testing.T) {
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}}
req := extractionRequest()
@@ -136,19 +173,27 @@ func TestExtractMapsRawSemanticCandidatesWithoutRepair(t *testing.T) {
}
}
func TestExtractHandlesCancellationAndProviderErrors(t *testing.T) {
func TestExtractRetainsLocalErrorContextAndProviderFailures(t *testing.T) {
request := extractionRequest()
extractor := newExtractor(t, &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}})
canceled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := extractor.Extract(canceled, request); err == nil || !strings.Contains(err.Error(), "context") {
t.Fatalf("canceled Extract() error = %v, want context error", err)
var nilExtractor *Extractor
for _, test := range []struct {
name string
extractor *Extractor
req contracts.TypedExtractionRequest
want string
}{
{name: "nil extractor", extractor: nilExtractor, req: request, want: "extractor"},
{name: "nil LLM client", extractor: &Extractor{}, req: request, want: "LLM client"},
{name: "wrapped preflight failure", extractor: extractor, req: mismatchedSourceInputRequest(request), want: "must match chunk"},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := test.extractor.Extract(context.Background(), test.req); err == nil || !strings.Contains(err.Error(), "dnd npcs") || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Extract() error = %v, want contextual local error", err)
}
_, err := extractor.Extract(context.Background(), mismatchedSourceInputRequest(request))
if err == nil || !strings.Contains(err.Error(), "dnd npcs") || !strings.Contains(err.Error(), "must match chunk") {
t.Fatalf("source input error = %v, want contextual source input error", err)
})
}
_, err = newExtractor(t, &fakeNPCsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request)
_, err := newExtractor(t, &fakeNPCsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request)
if err == nil || !strings.Contains(err.Error(), "dnd npcs") || !strings.Contains(err.Error(), "provider unavailable") {
t.Fatalf("provider Extract() error = %v, want contextual provider error", err)
}

View File

@@ -77,6 +77,7 @@ func TestExtractorMetadataAndCheckpointIdentity(t *testing.T) {
"response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID,
"response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion,
"identity_policy": "dnd.npcs.identity.v1",
"mapping_policy": mappingPolicy,
} {
if metadata[key] != want {
t.Fatalf("metadata[%q] = %#v, want %q", key, metadata[key], want)
@@ -88,7 +89,7 @@ func TestExtractorMetadataAndCheckpointIdentity(t *testing.T) {
}
}
fingerprints := extractor.CheckpointFingerprints()
want := map[string]string{"prompt": metadata["prompt_sha256"].(string), "response_schema": metadata["response_schema_sha256"].(string), "identity_policy": "dnd.npcs.identity.v1"}
want := map[string]string{"prompt": metadata["prompt_sha256"].(string), "response_schema": metadata["response_schema_sha256"].(string), "identity_policy": "dnd.npcs.identity.v1", "mapping_policy": mappingPolicy}
if len(fingerprints) != len(want) {
t.Fatalf("CheckpointFingerprints() = %#v, want %d entries", fingerprints, len(want))
}

View File

@@ -42,17 +42,45 @@ func TestRegisterPromptAssetsAndPrepareNPCPrompt(t *testing.T) {
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npcs_llm.v1.json" {
t.Fatalf("prepared prompt = %#v, want NPC prompt identity and wiring", prepared)
}
for _, want := range []string{`{"units":[1]}`, "Dana: Mira", "Mira: ranger", "Greencloak: title"} {
found := false
for _, message := range prepared.Messages {
if strings.Contains(message.Content, want) {
found = true
break
for index, want := range []struct {
role string
cached bool
marker string
}{
{role: "system", marker: "Dungeons & Dragons gameplay transcripts"},
{role: "user", marker: "Transcript units are the only evidence"},
{role: "user", cached: true, marker: "most specific supported in-world"},
{role: "user", cached: true},
{role: "user", marker: "individually identifiable"},
{role: "user", cached: true, marker: "observed display name"},
{role: "user"},
} {
if index >= len(prepared.Messages) {
t.Fatalf("prepared prompt has %d messages, want at least %d", len(prepared.Messages), index+1)
}
message := prepared.Messages[index]
if message.Role != want.role {
t.Errorf("message %d role = %q, want %q", index, message.Role, want.role)
}
if want.cached {
if message.CacheControl == nil || message.CacheControl.Type != scriptorium.CacheControlEphemeral {
t.Errorf("message %d cache control = %#v, want ephemeral", index, message.CacheControl)
}
} else if message.CacheControl != nil {
t.Errorf("message %d cache control = %#v, want nil", index, message.CacheControl)
}
if want.marker != "" && !strings.Contains(message.Content, want.marker) {
t.Errorf("message %d content does not contain purpose marker %q", index, want.marker)
}
}
if !found {
t.Fatalf("prepared prompt did not render required input %q", want)
if len(prepared.Messages) != 7 {
t.Fatalf("prepared prompt has %d messages, want 7", len(prepared.Messages))
}
if references := prepared.Messages[3].Content; !strings.Contains(references, "Dana: Mira") || !strings.Contains(references, "Mira: ranger") || !strings.Contains(references, "Greencloak: title") {
t.Fatalf("campaign references message = %q, want rendered reference inputs", references)
}
if transcript := prepared.Messages[6].Content; !strings.Contains(transcript, `{"units":[1]}`) {
t.Fatalf("final message = %q, want transcript", transcript)
}
transcriptMessages := 0
for _, message := range prepared.Messages {

View File

@@ -58,11 +58,6 @@ func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references
return extractor
}
func emptyChunkRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
req.Chunk = &source.Chunk{ID: req.Chunk.ID, SourceID: req.Chunk.SourceID, Index: req.Chunk.Index}
return req
}
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "file:///other.json")
return req

View File

@@ -6,16 +6,13 @@
"required": ["kind", "title", "summary"],
"properties": {
"kind": {
"type": "string",
"enum": ["combat", "narrative", "recap", "meta"]
"type": "string"
},
"title": {
"type": "string",
"minLength": 1
"type": "string"
},
"summary": {
"type": "string",
"minLength": 1
"type": "string"
}
}
}

View File

@@ -113,22 +113,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
if e.llm == nil {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("LLM client must not be nil")
}
if ctx == nil {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("context must not be nil")
}
if err := ctx.Err(); err != nil {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("context error before extraction: %w", err)
}
if req.Source == nil {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("source must not be nil")
}
if req.Chunk == nil {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("chunk must not be nil")
}
if len(req.Chunk.Units) == 0 {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
}
sourceInput, err := shared.ChunkPromptMaterial(req)
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
if err != nil {
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("%w", err)
}

View File

@@ -70,43 +70,47 @@ func TestExtractPassesOptionalReferencesAndUsesEmptyPlaceholders(t *testing.T) {
}
}
func TestExtractPreservesTheModelKindWithoutRepair(t *testing.T) {
func TestExtractReturnsSemanticallyInvalidResponseForDeterministicValidation(t *testing.T) {
schema, err := loadResponseSchema()
if err != nil {
t.Fatalf("loadResponseSchema() error = %v", err)
}
if err := validateJSONSchema(t, map[string]any{"kind": "unrecognized", "title": " ", "summary": ""}, schema.JSONSchema); err != nil {
t.Fatalf("semantic candidate rejected by private schema: %v", err)
}
client := &fakeSceneDescriptionsLLMClient{response: extractionResponse{
Kind: dnd.SceneKind("unrecognized"), Title: " Untitled scene ", Summary: " Summary ",
Kind: dnd.SceneKind("unrecognized"), Title: " ", Summary: "",
}}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
if err != nil {
t.Fatalf("Extract() error = %v, want nil", err)
}
scene := result.Value.Scenes[0]
if scene.Kind != dnd.SceneKind("unrecognized") || scene.Title != "Untitled scene" || scene.Summary != "Summary" {
t.Fatalf("scene = %#v, want model kind preserved and textual fields trimmed", scene)
if scene.Kind != dnd.SceneKind("unrecognized") || scene.Title != "" || scene.Summary != "" {
t.Fatalf("scene = %#v, want semantic candidates returned for deterministic validation", scene)
}
}
func TestExtractValidatesRequestsAndSurfacesProviderFailures(t *testing.T) {
request := extractionRequest()
extractor := newExtractor(t, &fakeSceneDescriptionsLLMClient{response: extractionResponse{Kind: dnd.SceneKindMeta, Title: "Table talk", Summary: "The group discusses rules."}})
var nilExtractor *Extractor
for _, test := range []struct {
name string
extractor *Extractor
req contracts.TypedExtractionRequest
want string
}{
{name: "nil source", req: func() contracts.TypedExtractionRequest { r := request; r.Source = nil; return r }()},
{name: "nil chunk", req: func() contracts.TypedExtractionRequest { r := request; r.Chunk = nil; return r }()},
{name: "empty chunk", req: emptyChunkRequest(request)},
{name: "mismatched source input", req: mismatchedSourceInputRequest(request)},
{name: "nil extractor", extractor: nilExtractor, req: request, want: "extractor"},
{name: "nil LLM client", extractor: &Extractor{}, req: request, want: "LLM client"},
{name: "wrapped preflight failure", extractor: extractor, req: mismatchedSourceInputRequest(request), want: "must match chunk"},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := extractor.Extract(context.Background(), test.req); err == nil || !strings.Contains(err.Error(), "dnd scene descriptions") {
if _, err := test.extractor.Extract(context.Background(), test.req); err == nil || !strings.Contains(err.Error(), "dnd scene descriptions") || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Extract() error = %v, want contextual validation error", err)
}
})
}
canceled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := extractor.Extract(canceled, request); err == nil || !strings.Contains(err.Error(), "context") {
t.Fatalf("canceled Extract() error = %v, want context error", err)
}
_, err := newExtractor(t, &fakeSceneDescriptionsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request)
if err == nil || !strings.Contains(err.Error(), "dnd scene descriptions") || !strings.Contains(err.Error(), "provider unavailable") {
t.Fatalf("provider Extract() error = %v, want contextual provider error", err)

View File

@@ -30,10 +30,12 @@ func TestLoadResponseSchemaUsesStrictPrivateSceneDescriptionContract(t *testing.
{name: "unknown framework field", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "id": "assigned-later"}},
{name: "unknown application field", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "source_ref": map[string]any{}}},
{name: "collection is not allowed", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "scenes": []any{}}},
{name: "unsupported kind", response: map[string]any{"kind": "interlude", "title": "Ambush", "summary": "Bandits strike."}},
{name: "wrong field type", response: map[string]any{"kind": "combat", "title": 7, "summary": "Bandits strike."}},
{name: "blank title", response: map[string]any{"kind": "combat", "title": "", "summary": "Bandits strike."}},
{name: "blank summary", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": ""}},
{name: "unsupported kind", response: map[string]any{"kind": "interlude", "title": "Ambush", "summary": "Bandits strike."}, valid: true},
{name: "empty title", response: map[string]any{"kind": "combat", "title": "", "summary": "Bandits strike."}, valid: true},
{name: "empty summary", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": ""}, valid: true},
{name: "wrong kind type", response: map[string]any{"kind": 7, "title": "Ambush", "summary": "Bandits strike."}},
{name: "wrong title type", response: map[string]any{"kind": "combat", "title": 7, "summary": "Bandits strike."}},
{name: "wrong summary type", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": 7}},
} {
t.Run(test.name, func(t *testing.T) {
err := validateJSONSchema(t, test.response, schema.JSONSchema)
@@ -54,7 +56,7 @@ func TestResponseSchemaIsMutationSafeAndDiagnosticsRedactContent(t *testing.T) {
}
first.JSONSchema[0] = '['
second, err := loadResponseSchema()
if err != nil || !json.Valid(second.JSONSchema) || bytes.Equal(first.JSONSchema, second.JSONSchema) {
if err != nil || !strings.HasPrefix(second.SHA256, "sha256:") || !json.Valid(second.JSONSchema) || bytes.Equal(first.JSONSchema, second.JSONSchema) {
t.Fatalf("second schema = %s, %v; want defensive copy", second.JSONSchema, err)
}
if diagnostics := second.DiagnosticsMap(); diagnostics["json_schema"] != nil {

View File

@@ -54,11 +54,6 @@ func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references
return extractor
}
func emptyChunkRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
req.Chunk = &source.Chunk{ID: req.Chunk.ID, SourceID: req.Chunk.SourceID, Index: req.Chunk.Index}
return req
}
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "file:///other.json")
return req

View File

@@ -5,75 +5,71 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
func canonicalizeResponse(response *extractionResponse) {
type orderedSpellResponse struct {
value spellCastResponse
earliest int
hasEvidence bool
}
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string) {
if response == nil {
return
}
ordered := make([]orderedSpellResponse, len(response.SpellCasts))
for index := range response.SpellCasts {
canonicalizeSpellCast(&response.SpellCasts[index])
earliest, hasEvidence := canonicalizeSpellCast(&response.SpellCasts[index], order, sourceID)
ordered[index] = orderedSpellResponse{
value: response.SpellCasts[index],
earliest: earliest,
hasEvidence: hasEvidence,
}
sort.SliceStable(response.SpellCasts, func(i, j int) bool {
left, leftOK := earliestSourceUnit(response.SpellCasts[i])
right, rightOK := earliestSourceUnit(response.SpellCasts[j])
if leftOK != rightOK {
return leftOK
}
if !leftOK {
sort.SliceStable(ordered, func(i, j int) bool {
if ordered[i].hasEvidence != ordered[j].hasEvidence {
return ordered[i].hasEvidence
}
if !ordered[i].hasEvidence {
return false
}
return left < right
return ordered[i].earliest < ordered[j].earliest
})
for index := range ordered {
response.SpellCasts[index] = ordered[index].value
}
}
func canonicalizeSpellCast(spell *spellCastResponse) {
sort.SliceStable(spell.SourceRefs, func(i, j int) bool {
left := spell.SourceRefs[i]
right := spell.SourceRefs[j]
if left.StartUnitID != right.StartUnitID {
return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID)
}
return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID)
})
spell.SourceRefs = dedupeSourceRefs(spell.SourceRefs)
}
func dedupeSourceRefs(refs []spellSourceRefResponse) []spellSourceRefResponse {
if len(refs) < 2 {
return refs
}
out := refs[:0]
var previous spellSourceRefResponse
for index, ref := range refs {
if index > 0 && sameSourceRef(previous, ref) {
continue
}
out = append(out, ref)
previous = ref
}
return out
}
func sameSourceRef(left spellSourceRefResponse, right spellSourceRefResponse) bool {
return left.StartUnitID == right.StartUnitID && left.EndUnitID == right.EndUnitID
}
func earliestSourceUnit(spell spellCastResponse) (int, bool) {
for _, ref := range spell.SourceRefs {
start := ref.StartUnitID
if start > 0 {
return start, true
}
}
func canonicalizeSpellCast(spell *spellCastResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
if spell == nil {
return 0, false
}
refs := order.Canonicalize(spellSourceRefs(spell.SourceRefs, sourceID))
spell.SourceRefs = spellResponseRefs(refs)
return order.EarliestValid(refs)
}
func unitSortValue(value int) int {
if value <= 0 {
return int(^uint(0) >> 1)
func spellSourceRefs(refs []spellSourceRefResponse, sourceID string) []source.SourceRef {
if refs == nil {
return nil
}
return value
values := make([]source.SourceRef, len(refs))
for index, ref := range refs {
values[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
}
return values
}
func spellResponseRefs(refs []source.SourceRef) []spellSourceRefResponse {
if refs == nil {
return nil
}
values := make([]spellSourceRefResponse, len(refs))
for index, ref := range refs {
values[index] = spellSourceRefResponse{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
}
return values
}
func canonicalSpellList(response extractionResponse, sourceID string) dnd.SpellList {

View File

@@ -14,9 +14,10 @@ import (
)
const Key = "dnd/spells"
const ArtifactType = "dnd.spell_cast"
const SchemaVersion = "v1"
const mappingPolicy = "dnd.spells.extract_mapping.v2"
const (
NPCRegistryReferenceSlot = npcregistry.ReferenceSlot
NPCRegistryMaxBytes = npcregistry.MaxBytes
@@ -120,6 +121,9 @@ func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot {
}
func (e *Extractor) ManifestMetadata() map[string]any {
if e == nil {
return nil
}
metadata := map[string]any{
"prompt_id": PromptID,
"prompt_version": SchemaVersion,
@@ -127,6 +131,7 @@ func (e *Extractor) ManifestMetadata() map[string]any {
"catalog_base_id": e.effectiveCatalog.BaseID(),
"catalog_digest": e.effectiveCatalog.Digest(),
"catalog_overlay_ids": e.effectiveCatalog.OverlayIDs(),
"mapping_policy": mappingPolicy,
"response_schema_key": string(ResponseSchemaKey),
"response_schema_id": ResponseSchemaID,
"response_schema_name": ResponseSchemaName,
@@ -147,6 +152,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
}
fingerprints := []pipeline.CheckpointFingerprint{
{Name: "effective_catalog", Value: e.effectiveCatalog.Digest()},
{Name: "mapping_policy", Value: mappingPolicy},
{Name: "prompt", Value: e.promptSHA},
{Name: "response_schema", Value: e.responseSchemaSHA},
}
@@ -162,25 +168,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
if e.llm == nil {
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("LLM client must not be nil")
}
if ctx == nil {
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("context must not be nil")
}
if err := ctx.Err(); err != nil {
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("context error before extraction: %w", err)
}
if req.Source == nil {
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("source must not be nil")
}
if req.Chunk == nil {
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("chunk must not be nil")
}
if len(req.Chunk.Units) == 0 {
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
}
sourceInput, err := shared.ChunkPromptMaterial(req)
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
if err != nil {
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("%w", err)
}
order := shared.NewSourceRefOrder(req.Source)
npcRegistry, err := e.npcResolver.Resolve(req.References)
if err != nil {
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("resolve NPC registry: %w", err)
@@ -200,7 +192,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
}, &response); err != nil {
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("complete structured output: %w", err)
}
canonicalizeResponse(&response)
canonicalizeResponse(&response, order, req.Source.ID)
return contracts.TypedExtractionResult[dnd.SpellList]{Value: canonicalSpellList(response, req.Source.ID)}, nil
}

View File

@@ -104,6 +104,9 @@ func TestExtractPromptUsesCanonicalOverlayNamesWithoutAliasesOrMetadata(t *testi
}
metadata := newExtractor(t, &fakeSpellsLLMClient{}, overlaySpellCatalogReference()).ManifestMetadata()
if metadata["mapping_policy"] != mappingPolicy {
t.Fatalf("mapping policy metadata = %#v, want %q", metadata["mapping_policy"], mappingPolicy)
}
if metadata["catalog_base_id"] != spellcatalog.SRD5E2014ID {
t.Fatalf("catalog base metadata = %#v", metadata["catalog_base_id"])
}
@@ -116,6 +119,7 @@ func TestExtractPromptUsesCanonicalOverlayNamesWithoutAliasesOrMetadata(t *testi
fingerprints := newExtractor(t, &fakeSpellsLLMClient{}, overlaySpellCatalogReference()).CheckpointFingerprints()
wantFingerprints := map[string]any{
"effective_catalog": metadata["catalog_digest"],
"mapping_policy": mappingPolicy,
"prompt": metadata["prompt_sha256"],
"response_schema": metadata["response_schema_sha256"],
"npc_registry": checkpointFingerprintMap(newExtractor(t, &fakeSpellsLLMClient{}).CheckpointFingerprints())["npc_registry"],
@@ -170,6 +174,13 @@ func TestExtractorManifestMetadataIncludesLLMSchemaProvenance(t *testing.T) {
}
}
func TestNilExtractorManifestMetadata(t *testing.T) {
var extractor *Extractor
if metadata := extractor.ManifestMetadata(); metadata != nil {
t.Fatalf("nil extractor metadata = %#v, want nil", metadata)
}
}
func TestExtractPassesReferencesAsPromptInputs(t *testing.T) {
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
req := extractionRequest()
@@ -234,9 +245,8 @@ func TestExtractWrapsLLMClientError(t *testing.T) {
func TestExtractRejectsInvalidRequests(t *testing.T) {
validReq := extractionRequest()
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
validExtractor := newExtractor(t, &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}})
var nilExtractor *Extractor
tests := []struct {
name string
extractor *Extractor
@@ -244,13 +254,9 @@ func TestExtractRejectsInvalidRequests(t *testing.T) {
req contracts.TypedExtractionRequest
want string
}{
{name: "nil extractor", ctx: context.Background(), req: validReq, want: "extractor"},
{name: "nil context", extractor: validExtractor, req: validReq, want: "context"},
{name: "canceled context", extractor: validExtractor, ctx: canceledCtx, req: validReq, want: "context"},
{name: "nil source", extractor: validExtractor, ctx: context.Background(), req: contracts.TypedExtractionRequest{Chunk: validReq.Chunk}, want: "source"},
{name: "nil chunk", extractor: validExtractor, ctx: context.Background(), req: contracts.TypedExtractionRequest{Source: validReq.Source}, want: "chunk"},
{name: "empty chunk units", extractor: validExtractor, ctx: context.Background(), req: emptyChunkRequest(validReq), want: "units"},
{name: "source input mismatches chunk", extractor: validExtractor, ctx: context.Background(), req: mismatchedSourceInputRequest(validReq), want: "must match chunk"},
{name: "nil extractor", extractor: nilExtractor, ctx: context.Background(), req: validReq, want: "extractor"},
{name: "nil LLM client", extractor: &Extractor{}, ctx: context.Background(), req: validReq, want: "LLM client"},
{name: "wrapped preflight failure", extractor: validExtractor, ctx: context.Background(), req: mismatchedSourceInputRequest(validReq), want: "must match chunk"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -280,6 +286,57 @@ func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) {
}
}
func TestExtractUsesDocumentOrderForReferencesAndSpellCasts(t *testing.T) {
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
{Caster: "Later", Spell: "Fire Bolt", SourceRefs: responseSourceRefs(10, 10)},
{Caster: "Earlier", Spell: "Cure Wounds", SourceRefs: []spellSourceRefResponse{
{StartUnitID: 10, EndUnitID: 10},
{StartUnitID: 30, EndUnitID: 30},
{StartUnitID: 30, EndUnitID: 30},
{StartUnitID: 999, EndUnitID: 0},
}},
{Caster: "Unavailable", Spell: "Healing Word", SourceRefs: []spellSourceRefResponse{{StartUnitID: 999, EndUnitID: 0}}},
}}}
req := extractionRequest()
req.Source.Units = []source.SourceUnit{{ID: 30}, {ID: 10}}
req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...)
req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 10}
result, err := newExtractor(t, client).Extract(context.Background(), req)
if err != nil {
t.Fatalf("Extract() error = %v", err)
}
if got := []string{result.Value.SpellCasts[0].Spell, result.Value.SpellCasts[1].Spell, result.Value.SpellCasts[2].Spell}; !reflect.DeepEqual(got, []string{"Cure Wounds", "Fire Bolt", "Healing Word"}) {
t.Fatalf("spell order = %#v, want document chronology followed by invalid evidence", got)
}
refs := result.Value.SpellCasts[0].SourceRefs
if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) {
t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs)
}
refs[0].StartUnitID = 777
for _, spell := range client.response.SpellCasts {
for _, ref := range spell.SourceRefs {
if ref.StartUnitID == 777 {
t.Fatal("result source references alias the model response")
}
}
}
}
func TestExtractPreservesStableSpellOrderForEqualEvidence(t *testing.T) {
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
{Caster: "First", Spell: "Cure Wounds", SourceRefs: responseSourceRefs(2, 2)},
{Caster: "Second", Spell: "Fire Bolt", SourceRefs: responseSourceRefs(2, 2)},
}}}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
if err != nil {
t.Fatalf("Extract() error = %v", err)
}
if got := []string{result.Value.SpellCasts[0].Caster, result.Value.SpellCasts[1].Caster}; !reflect.DeepEqual(got, []string{"First", "Second"}) {
t.Fatalf("equal-evidence order = %#v, want stable response order", got)
}
}
func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) {
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{
Caster: "Aria", Spell: "Cure Wounds",

View File

@@ -21,24 +21,53 @@ func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing
if prepared.OutputContract.SchemaPath != "dnd_spells_llm.v1.json" {
t.Fatalf("schema path = %q, want LLM-only schema", prepared.OutputContract.SchemaPath)
}
for _, want := range []string{
string(transcript),
"Dana: Mira",
"Mira: wizard",
"Shield: abjuration",
`{"spell_names":["Cure Wounds"]}`,
`{"npcs":[]}`,
for index, want := range []struct {
role string
cached bool
marker string
}{
{role: "system", marker: "Dungeons & Dragons gameplay transcripts"},
{role: "user", marker: "Transcript units are the only evidence"},
{role: "user", cached: true, marker: "most specific supported in-world"},
{role: "user", cached: true},
{role: "user", cached: true},
{role: "user"},
{role: "user", marker: "spell-cast artifacts"},
{role: "user", cached: true, marker: "source references must collectively support"},
{role: "user"},
} {
found := false
for _, message := range prepared.Messages {
if strings.Contains(message.Content, want) {
found = true
break
if index >= len(prepared.Messages) {
t.Fatalf("prepared prompt has %d messages, want at least %d", len(prepared.Messages), index+1)
}
message := prepared.Messages[index]
if message.Role != want.role {
t.Errorf("message %d role = %q, want %q", index, message.Role, want.role)
}
if want.cached {
if message.CacheControl == nil || message.CacheControl.Type != scriptorium.CacheControlEphemeral {
t.Errorf("message %d cache control = %#v, want ephemeral", index, message.CacheControl)
}
} else if message.CacheControl != nil {
t.Errorf("message %d cache control = %#v, want nil", index, message.CacheControl)
}
if want.marker != "" && !strings.Contains(message.Content, want.marker) {
t.Errorf("message %d content does not contain purpose marker %q", index, want.marker)
}
}
if !found {
t.Fatalf("prepared prompt did not render required input %q", want)
if len(prepared.Messages) != 9 {
t.Fatalf("prepared prompt has %d messages, want 9", len(prepared.Messages))
}
if references := prepared.Messages[3].Content; !strings.Contains(references, "Dana: Mira") || !strings.Contains(references, "Mira: wizard") || !strings.Contains(references, "Shield: abjuration") {
t.Fatalf("campaign references message = %q, want rendered reference inputs", references)
}
if registry := prepared.Messages[4].Content; !strings.Contains(registry, `{"npcs":[]}`) {
t.Fatalf("NPC registry message = %q, want registry input", registry)
}
if catalog := prepared.Messages[5].Content; !strings.Contains(catalog, `{"spell_names":["Cure Wounds"]}`) {
t.Fatalf("spell catalog message = %q, want catalog input", catalog)
}
if final := prepared.Messages[8].Content; !strings.Contains(final, string(transcript)) {
t.Fatalf("final message = %q, want transcript", final)
}
}

View File

@@ -99,11 +99,6 @@ func extractionRequest() contracts.TypedExtractionRequest {
return req
}
func emptyChunkRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
req.Chunk = &source.Chunk{ID: req.Chunk.ID, SourceID: req.Chunk.SourceID, Index: req.Chunk.Index}
return req
}
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
req.SourceInput = spellSourceInput()
return req

View File

@@ -14,12 +14,13 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
)
const (
Key = "dnd/combat-turns"
normalizationPolicy = "dnd.combat_turns.normalize.v1"
normalizationPolicy = "dnd.combat_turns.normalize.v2"
NormalizationPolicy = normalizationPolicy
ReasonCodeActorCanonicalized = "combat_actor_canonicalized"
@@ -109,7 +110,9 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
if err != nil {
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("resolve NPC registry: %w", err)
}
value, warnings := normalizeList(req.MergeOutput.Value, req.Source, npcRegistry)
index := source.NewDocumentIndex(req.Source)
order := shared.NewSourceRefOrderWithIndex(req.Source, index)
value, warnings := normalizeList(req.MergeOutput.Value, index, order, npcRegistry)
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{Value: value, Warnings: warnings}, nil
}
@@ -125,7 +128,7 @@ type actorCanonicalization struct {
to string
}
func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) {
func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) {
if input.CombatTurns == nil {
return dnd.CombatTurnList{}, nil
}
@@ -133,8 +136,8 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
records := make([]normalizedRecord, len(input.CombatTurns))
warnings := make([]contracts.Warning, 0)
for index, inputTurn := range input.CombatTurns {
turn, actorChange, refsChanged := normalizeTurn(inputTurn, registry)
earliest, hasEvidence := earliestSourcePosition(doc, turn)
turn, actorChange, refsChanged := normalizeTurn(inputTurn, order, registry)
earliest, hasEvidence := order.EarliestValid(turn.SourceRefs)
records[index] = normalizedRecord{
turn: turn,
inputIndex: index,
@@ -180,12 +183,12 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
})
}
output, duplicateWarnings := collapseDuplicates(records, doc)
output, duplicateWarnings := collapseDuplicates(records, documentIndex)
warnings = append(warnings, duplicateWarnings...)
return dnd.CombatTurnList{CombatTurns: output}, warnings
}
func normalizeTurn(input dnd.CombatTurn, registry *npcregistry.Registry) (dnd.CombatTurn, *actorCanonicalization, bool) {
func normalizeTurn(input dnd.CombatTurn, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.CombatTurn, *actorCanonicalization, bool) {
output := cloneCombatTurn(input)
output.Actor = identity.NormalizeDisplay(input.Actor)
@@ -198,8 +201,7 @@ func normalizeTurn(input dnd.CombatTurn, registry *npcregistry.Registry) (dnd.Co
actorChange = &actorCanonicalization{from: input.Actor, to: output.Actor}
}
canonicalRefs, _, _ := canonicalizeSourceRefs(input.SourceRefs)
output.SourceRefs = canonicalRefs
output.SourceRefs = order.Canonicalize(input.SourceRefs)
refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs)
return output, actorChange, refsChanged
}
@@ -225,70 +227,12 @@ func sourceRefsEqual(left, right []source.SourceRef) bool {
return true
}
func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) {
if input == nil {
return nil, false, 0
}
canonical := make([]source.SourceRef, len(input))
copy(canonical, input)
sort.SliceStable(canonical, func(left, right int) bool {
return sourceRefLess(canonical[left], canonical[right])
})
orderChanged := false
for index := range input {
if input[index] != canonical[index] {
orderChanged = true
break
}
}
unique := make([]source.SourceRef, 0, len(canonical))
for _, ref := range canonical {
if len(unique) == 0 || unique[len(unique)-1] != ref {
unique = append(unique, ref)
}
}
return unique, orderChanged, len(input) - len(unique)
}
func sourceRefLess(left, right source.SourceRef) bool {
if left.SourceID != right.SourceID {
return left.SourceID < right.SourceID
}
if left.StartUnitID != right.StartUnitID {
return left.StartUnitID < right.StartUnitID
}
return left.EndUnitID < right.EndUnitID
}
func earliestSourcePosition(doc *source.SourceDocument, turn dnd.CombatTurn) (int, bool) {
if doc == nil {
return 0, false
}
earliest := 0
found := false
for _, ref := range turn.SourceRefs {
if source.ValidateRef(doc, ref) != nil {
continue
}
index, ok := source.UnitIndex(doc, ref.StartUnitID)
if !ok || (found && index >= earliest) {
continue
}
earliest = index
found = true
}
return earliest, found
}
type duplicateGroup struct {
retainedIndex int
removed []int
}
func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument) ([]dnd.CombatTurn, []contracts.Warning) {
func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.CombatTurn, []contracts.Warning) {
if len(records) == 0 {
return make([]dnd.CombatTurn, 0), nil
}
@@ -297,7 +241,7 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
groups := make([]duplicateGroup, 0)
groupByKey := make(map[string]int)
for index, record := range records {
key, eligible := duplicateKey(record.turn, doc)
key, eligible := duplicateKey(record.turn, documentIndex)
if !eligible {
keep[index] = true
continue
@@ -329,12 +273,12 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
return output, warnings
}
func duplicateKey(turn dnd.CombatTurn, doc *source.SourceDocument) (string, bool) {
func duplicateKey(turn dnd.CombatTurn, documentIndex source.DocumentIndex) (string, bool) {
if len(turn.SourceRefs) == 0 {
return "", false
}
for _, ref := range turn.SourceRefs {
if source.ValidateRef(doc, ref) != nil {
if documentIndex.ValidateRef(ref) != nil {
return "", false
}
}

View File

@@ -122,6 +122,34 @@ func TestNormalizeOrdersBySourcePositionAndCollapsesExactDuplicates(t *testing.T
}
}
func TestNormalizeUsesDocumentOrderForReferencesAndChronology(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
validTurn("Borin", source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}),
validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}),
{Actor: "Goblin", TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
}},
}}
normalizer, err := New(Options{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}})
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
if got := result.Value.CombatTurns; got[0].Actor != "Aria" || got[1].Actor != "Goblin" || got[2].Actor != "Borin" {
t.Fatalf("turn order = %#v, want source chronology", got)
}
refs := result.Value.CombatTurns[1].SourceRefs
if refs[0].StartUnitID != 30 || refs[1].StartUnitID != 10 || refs[2].StartUnitID != 999 {
t.Fatalf("turn source refs = %#v, want source-document order and invalid fallback", refs)
}
}
func TestNormalizePreservesStableOrderForEqualEvidencePositions(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}}}
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{

View File

@@ -118,7 +118,9 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
if !registry.Bound() {
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("NPC registry reference is required")
}
value, warnings := normalizeList(req.MergeOutput.Value, req.Source, registry)
index := source.NewDocumentIndex(req.Source)
order := shared.NewSourceRefOrderWithIndex(req.Source, index)
value, warnings := normalizeList(req.MergeOutput.Value, index, order, registry)
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{Value: value, Warnings: warnings}, nil
}
@@ -132,7 +134,7 @@ type nameCanonicalization struct {
to string
}
func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.NPCInteractionList, []contracts.Warning) {
func normalizeList(input dnd.NPCInteractionList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteractionList, []contracts.Warning) {
if input.Interactions == nil {
return dnd.NPCInteractionList{}, nil
}
@@ -140,7 +142,7 @@ func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, reg
records := make([]normalizedRecord, len(input.Interactions))
warnings := make([]contracts.Warning, 0)
for index, inputInteraction := range input.Interactions {
interaction, nameChange, refsChanged := normalizeInteraction(inputInteraction, doc, registry)
interaction, nameChange, refsChanged := normalizeInteraction(inputInteraction, order, registry)
records[index] = normalizedRecord{interaction: interaction, inputIndex: index}
if nameChange != nil {
warnings = append(warnings, contracts.Warning{
@@ -161,7 +163,7 @@ func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, reg
}
sort.SliceStable(records, func(left, right int) bool {
return interactionmodel.Less(doc, records[left].interaction, records[right].interaction)
return interactionmodel.Less(order, records[left].interaction, records[right].interaction)
})
for position, record := range records {
if position == record.inputIndex {
@@ -174,13 +176,13 @@ func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, reg
})
}
output, duplicateWarnings := collapseDuplicates(records, doc)
output, duplicateWarnings := collapseDuplicates(records, documentIndex)
warnings = append(warnings, duplicateWarnings...)
return dnd.NPCInteractionList{Interactions: output},
diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted)
}
func normalizeInteraction(input dnd.NPCInteraction, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.NPCInteraction, *nameCanonicalization, bool) {
func normalizeInteraction(input dnd.NPCInteraction, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteraction, *nameCanonicalization, bool) {
output := cloneInteraction(input)
if canonical, ok := registry.Lookup(identity.NormalizeDisplay(input.Name)); ok {
output.Name = canonical.Name
@@ -189,7 +191,7 @@ func normalizeInteraction(input dnd.NPCInteraction, doc *source.SourceDocument,
if input.Name != output.Name {
nameChange = &nameCanonicalization{from: input.Name, to: output.Name}
}
output.SourceRefs = interactionmodel.CanonicalizeSourceRefs(doc, input.SourceRefs)
output.SourceRefs = order.Canonicalize(input.SourceRefs)
return output, nameChange, !interactionmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs)
}
@@ -206,7 +208,7 @@ type duplicateGroup struct {
removed []int
}
func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument) ([]dnd.NPCInteraction, []contracts.Warning) {
func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.NPCInteraction, []contracts.Warning) {
if len(records) == 0 {
return make([]dnd.NPCInteraction, 0), nil
}
@@ -214,7 +216,7 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
groups := make([]duplicateGroup, 0)
groupByKey := make(map[string]int)
for index, record := range records {
if !interactionmodel.ValidSourceRefs(doc, record.interaction.SourceRefs) {
if !interactionmodel.ValidSourceRefs(documentIndex, record.interaction.SourceRefs) {
keep[index] = true
continue
}

View File

@@ -15,7 +15,7 @@ import (
)
func TestNormalizeCanonicalizesAndClones(t *testing.T) {
doc := testDocument()
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
normalizer, err := New(Options{}, npcReferences(t))
if err != nil {
t.Fatalf("New() error = %v", err)
@@ -30,7 +30,7 @@ func TestNormalizeCanonicalizesAndClones(t *testing.T) {
t.Fatalf("Normalize() error = %v", err)
}
got := result.Value.Interactions[0]
if got.Name != "Ária" || !reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}) {
if got.Name != "Ária" || !reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}) {
t.Fatalf("normalized interaction = %#v", got)
}
if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) {

View File

@@ -5,7 +5,6 @@ import (
"context"
"fmt"
"reflect"
"sort"
"strconv"
"strings"
@@ -14,12 +13,13 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
)
const (
Key = "dnd/npcs"
normalizationPolicy = "dnd.npcs.normalize.v1"
normalizationPolicy = "dnd.npcs.normalize.v2"
NormalizationPolicy = normalizationPolicy
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
@@ -69,7 +69,8 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
if err := ctx.Err(); err != nil {
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context error before normalize: %w", err)
}
value, warnings := normalizeList(req.MergeOutput.Value)
order := shared.NewSourceRefOrder(req.Source)
value, warnings := normalizeList(req.MergeOutput.Value, order)
return contracts.TypedNormalizeResult[dnd.NPCList]{Value: value, Warnings: warnings}, nil
}
@@ -77,14 +78,14 @@ type normalizedRecord struct {
npc dnd.NPC
}
func normalizeList(input dnd.NPCList) (dnd.NPCList, []contracts.Warning) {
func normalizeList(input dnd.NPCList, order shared.SourceRefOrder) (dnd.NPCList, []contracts.Warning) {
if input.NPCs == nil {
return dnd.NPCList{}, nil
}
records := make([]normalizedRecord, len(input.NPCs))
warnings := make([]contracts.Warning, 0)
for index, inputNPC := range input.NPCs {
npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC)
npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC, order)
records[index] = normalizedRecord{npc: npc}
if fieldsChanged {
warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCFieldsNormalized, Message: fmt.Sprintf("input index %d: NPC name normalized for %s", index, diagnostics.Quote(inputNPC.Name))})
@@ -100,7 +101,7 @@ func normalizeList(input dnd.NPCList) (dnd.NPCList, []contracts.Warning) {
groups := canonicalNameGroups(records)
output := dnd.NPCList{NPCs: make([]dnd.NPC, 0, len(groups))}
for _, members := range groups {
consolidated, referencesChanged := consolidate(records, members)
consolidated, referencesChanged := consolidate(records, members, order)
retainedIndex := members[0]
output.NPCs = append(output.NPCs, consolidated)
if referencesChanged {
@@ -113,10 +114,10 @@ func normalizeList(input dnd.NPCList) (dnd.NPCList, []contracts.Warning) {
return output, warnings
}
func normalizeRecord(input dnd.NPC) (dnd.NPC, bool, bool) {
func normalizeRecord(input dnd.NPC, order shared.SourceRefOrder) (dnd.NPC, bool, bool) {
output := cloneNPC(input)
output.Name = identity.NormalizeDisplay(input.Name)
output.SourceRefs, _, _ = canonicalizeSourceRefs(input.SourceRefs)
output.SourceRefs = order.Canonicalize(input.SourceRefs)
output.ID = identity.DeriveID(output.Name)
return output, input.Name != output.Name, !reflect.DeepEqual(input.SourceRefs, output.SourceRefs)
}
@@ -143,41 +144,17 @@ func canonicalNameGroups(records []normalizedRecord) [][]int {
return groups
}
func consolidate(records []normalizedRecord, members []int) (dnd.NPC, bool) {
func consolidate(records []normalizedRecord, members []int, order shared.SourceRefOrder) (dnd.NPC, bool) {
output := cloneNPC(records[members[0]].npc)
originalRefs := cloneSourceRefs(output.SourceRefs)
for _, member := range members[1:] {
output.SourceRefs = append(output.SourceRefs, records[member].npc.SourceRefs...)
}
output.SourceRefs, _, _ = canonicalizeSourceRefs(output.SourceRefs)
output.SourceRefs = order.Canonicalize(output.SourceRefs)
output.ID = identity.DeriveID(output.Name)
return output, !reflect.DeepEqual(originalRefs, output.SourceRefs)
}
func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) {
if input == nil {
return nil, false, 0
}
canonical := cloneSourceRefs(input)
sort.SliceStable(canonical, func(left, right int) bool {
if canonical[left].SourceID != canonical[right].SourceID {
return canonical[left].SourceID < canonical[right].SourceID
}
if canonical[left].StartUnitID != canonical[right].StartUnitID {
return canonical[left].StartUnitID < canonical[right].StartUnitID
}
return canonical[left].EndUnitID < canonical[right].EndUnitID
})
orderChanged := !reflect.DeepEqual(input, canonical)
unique := make([]source.SourceRef, 0, len(canonical))
for _, ref := range canonical {
if len(unique) == 0 || unique[len(unique)-1] != ref {
unique = append(unique, ref)
}
}
return unique, orderChanged, len(input) - len(unique)
}
func cloneSourceRefs(input []source.SourceRef) []source.SourceRef {
if input == nil {
return nil

View File

@@ -61,6 +61,23 @@ func TestNormalizeNamesEvidenceAndIDs(t *testing.T) {
}
}
func TestNormalizeOrdersEvidenceBySourceDocumentPosition(t *testing.T) {
doc := &source.SourceDocument{ID: "source", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
input := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Lady Ash", SourceRefs: []source.SourceRef{
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
}}}}
result, err := New(Options{}).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
got := result.Value.NPCs[0].SourceRefs
if got[0].StartUnitID != 30 || got[1].StartUnitID != 10 || got[2].StartUnitID != 999 {
t.Fatalf("source refs = %#v, want source-document order followed by invalid reference", got)
}
}
func TestNormalizeConsolidatesCanonicalNamesOnlyAndUnionsEvidence(t *testing.T) {
input := dnd.NPCList{NPCs: []dnd.NPC{
{Name: " Captain Vale ", SourceRefs: []source.SourceRef{{SourceID: "a", StartUnitID: 1, EndUnitID: 1}}},
@@ -115,6 +132,12 @@ func normalizeRequest(value dnd.NPCList) contracts.TypedNormalizeRequest[dnd.NPC
return contracts.TypedNormalizeRequest[dnd.NPCList]{MergeOutput: contracts.MergeArtifact[dnd.NPCList]{Value: value}}
}
func normalizeRequestWithSource(value dnd.NPCList, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.NPCList] {
request := normalizeRequest(value)
request.Source = doc
return request
}
func hasWarning(warnings []contracts.Warning, reason, scope string) bool {
for _, warning := range warnings {
if warning.ReasonCode == reason && warning.Scope == scope {

View File

@@ -78,26 +78,23 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) (
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes must not be empty")
}
unitPositions := make(map[int]int, len(doc.Units))
for index, unit := range doc.Units {
unitPositions[unit.ID] = index
}
documentIndex := source.NewDocumentIndex(doc)
output := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, len(input.Scenes))}
for index, scene := range input.Scenes {
for sceneIndex, scene := range input.Scenes {
scene.Title = strings.TrimSpace(scene.Title)
scene.Summary = strings.TrimSpace(scene.Summary)
if err := shape.Validate(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{scene}}); err != nil {
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d]: %w", index, err)
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d]: %w", sceneIndex, err)
}
if err := source.ValidateRef(doc, scene.SourceRef); err != nil {
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d].source_ref: %s", index, diagnostics.Truncate(err.Error()))
if err := documentIndex.ValidateRef(scene.SourceRef); err != nil {
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d].source_ref: %s", sceneIndex, diagnostics.Truncate(err.Error()))
}
output.Scenes[index] = scene
output.Scenes[sceneIndex] = scene
}
sort.SliceStable(output.Scenes, func(left, right int) bool {
leftStart := unitPositions[output.Scenes[left].SourceRef.StartUnitID]
rightStart := unitPositions[output.Scenes[right].SourceRef.StartUnitID]
leftStart, _ := documentIndex.Position(output.Scenes[left].SourceRef.StartUnitID)
rightStart, _ := documentIndex.Position(output.Scenes[right].SourceRef.StartUnitID)
if leftStart != rightStart {
return leftStart < rightStart
}

View File

@@ -3,7 +3,6 @@ package spells
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
@@ -11,11 +10,16 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
"golang.org/x/text/cases"
)
const Key = "dnd/spells"
const (
Key = "dnd/spells"
normalizationPolicy = "dnd.spells.normalize.v2"
NormalizationPolicy = normalizationPolicy
)
const (
ReasonCodeSpellNameCanonicalized = "spell_name_canonicalized"
@@ -68,6 +72,7 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
"catalog_base_id": n.effectiveCatalog.BaseID(),
"catalog_digest": n.effectiveCatalog.Digest(),
"catalog_overlay_ids": append([]string(nil), n.effectiveCatalog.OverlayIDs()...),
"normalization_policy": normalizationPolicy,
}
}
@@ -75,7 +80,10 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if n == nil {
return nil
}
return []pipeline.CheckpointFingerprint{{Name: "effective_catalog", Value: n.effectiveCatalog.Digest()}}
return []pipeline.CheckpointFingerprint{
{Name: "effective_catalog", Value: n.effectiveCatalog.Digest()},
{Name: "normalization_policy", Value: normalizationPolicy},
}
}
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.SpellList]) (contracts.TypedNormalizeResult[dnd.SpellList], error) {
@@ -89,13 +97,15 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("context error before normalize: %w", err)
}
value, warnings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog)
value, duplicateWarnings := collapseDuplicateSpellCasts(value, req.Source, n.effectiveCatalog)
index := source.NewDocumentIndex(req.Source)
order := shared.NewSourceRefOrderWithIndex(req.Source, index)
value, warnings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog, order)
value, duplicateWarnings := collapseDuplicateSpellCasts(value, index, n.effectiveCatalog)
warnings = append(warnings, duplicateWarnings...)
return contracts.TypedNormalizeResult[dnd.SpellList]{Value: value, Warnings: warnings}, nil
}
func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatalog) (dnd.SpellList, []contracts.Warning) {
func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatalog, order shared.SourceRefOrder) (dnd.SpellList, []contracts.Warning) {
var warnings []contracts.Warning
if input.SpellCasts == nil {
return dnd.SpellList{}, nil
@@ -123,7 +133,7 @@ func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatal
})
}
canonicalRefs, orderChanged, duplicateCount := canonicalizeSourceRefs(inputCast.SourceRefs)
canonicalRefs, orderChanged, duplicateCount := canonicalizeSourceRefs(order, inputCast.SourceRefs)
cast.SourceRefs = canonicalRefs
if orderChanged || duplicateCount > 0 {
warnings = append(warnings, contracts.Warning{
@@ -147,42 +157,16 @@ func cloneSpellCast(input dnd.SpellCast) dnd.SpellCast {
return output
}
func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) {
if input == nil {
return nil, false, 0
}
canonical := make([]source.SourceRef, len(input))
copy(canonical, input)
sort.SliceStable(canonical, func(left, right int) bool {
return sourceRefLess(canonical[left], canonical[right])
})
func canonicalizeSourceRefs(order shared.SourceRefOrder, input []source.SourceRef) ([]source.SourceRef, bool, int) {
orderChanged := false
for index := range input {
if input[index] != canonical[index] {
for index := 1; index < len(input); index++ {
if order.Less(input[index], input[index-1]) {
orderChanged = true
break
}
}
unique := make([]source.SourceRef, 0, len(canonical))
for _, ref := range canonical {
if len(unique) == 0 || unique[len(unique)-1] != ref {
unique = append(unique, ref)
}
}
return unique, orderChanged, len(input) - len(unique)
}
func sourceRefLess(left, right source.SourceRef) bool {
if left.SourceID != right.SourceID {
return left.SourceID < right.SourceID
}
if left.StartUnitID != right.StartUnitID {
return left.StartUnitID < right.StartUnitID
}
return left.EndUnitID < right.EndUnitID
canonical := order.Canonicalize(input)
return canonical, orderChanged, len(input) - len(canonical)
}
type duplicateGroup struct {
@@ -190,7 +174,7 @@ type duplicateGroup struct {
removed []int
}
func collapseDuplicateSpellCasts(input dnd.SpellList, doc *source.SourceDocument, catalog spellcatalog.EffectiveCatalog) (dnd.SpellList, []contracts.Warning) {
func collapseDuplicateSpellCasts(input dnd.SpellList, documentIndex source.DocumentIndex, catalog spellcatalog.EffectiveCatalog) (dnd.SpellList, []contracts.Warning) {
if len(input.SpellCasts) == 0 {
return input, nil
}
@@ -199,7 +183,7 @@ func collapseDuplicateSpellCasts(input dnd.SpellList, doc *source.SourceDocument
groups := make([]duplicateGroup, 0)
groupByKey := make(map[string]int)
for index, cast := range input.SpellCasts {
key, eligible := duplicateKey(cast, doc, catalog)
key, eligible := duplicateKey(cast, documentIndex, catalog)
if !eligible {
keep[index] = true
continue
@@ -242,13 +226,13 @@ func collapseDuplicateSpellCasts(input dnd.SpellList, doc *source.SourceDocument
return output, warnings
}
func duplicateKey(cast dnd.SpellCast, doc *source.SourceDocument, catalog spellcatalog.EffectiveCatalog) (string, bool) {
func duplicateKey(cast dnd.SpellCast, documentIndex source.DocumentIndex, catalog spellcatalog.EffectiveCatalog) (string, bool) {
canonicalName, resolved := catalog.Lookup(cast.Spell)
if !resolved || len(cast.SourceRefs) == 0 {
return "", false
}
for _, ref := range cast.SourceRefs {
if source.ValidateRef(doc, ref) != nil {
if documentIndex.ValidateRef(ref) != nil {
return "", false
}
}

View File

@@ -104,19 +104,22 @@ func TestIdentityAndMetadataAreDefensive(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if NormalizationPolicy != "dnd.spells.normalize.v2" {
t.Fatalf("NormalizationPolicy = %q, want v2 policy", NormalizationPolicy)
}
fingerprints := normalizer.CheckpointFingerprints()
if len(fingerprints) != 1 || fingerprints[0].Name != "effective_catalog" || fingerprints[0].Value != normalizer.effectiveCatalog.Digest() {
t.Fatalf("fingerprints = %#v, want effective catalog fingerprint", fingerprints)
if len(fingerprints) != 2 || fingerprints[0].Name != "effective_catalog" || fingerprints[0].Value != normalizer.effectiveCatalog.Digest() || fingerprints[1] != (pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy}) {
t.Fatalf("fingerprints = %#v, want catalog and normalization policy fingerprints", fingerprints)
}
fingerprints[0].Name = "changed"
fingerprints[0].Value = "changed"
if got := normalizer.CheckpointFingerprints(); len(got) != 1 || got[0].Name != "effective_catalog" || got[0].Value != normalizer.effectiveCatalog.Digest() {
if got := normalizer.CheckpointFingerprints(); len(got) != 2 || got[0].Name != "effective_catalog" || got[0].Value != normalizer.effectiveCatalog.Digest() || got[1] != (pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy}) {
t.Fatalf("fingerprints were not defensive: %#v", got)
}
metadata := normalizer.ManifestMetadata()
if metadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || metadata["catalog_digest"] != normalizer.effectiveCatalog.Digest() {
if metadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || metadata["catalog_digest"] != normalizer.effectiveCatalog.Digest() || metadata["normalization_policy"] != normalizationPolicy {
t.Fatalf("metadata = %#v, want catalog identity", metadata)
}
metadata["catalog_overlay_ids"].([]string)[0] = "changed"
@@ -205,11 +208,52 @@ func TestNormalizeSortsAndDeduplicatesExactSourceReferences(t *testing.T) {
if !reflect.DeepEqual(result.Value.SpellCasts[0].SourceRefs, wantRefs) {
t.Fatalf("source refs = %#v, want %#v", result.Value.SpellCasts[0].SourceRefs, wantRefs)
}
if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != ReasonCodeSourceReferencesNormalized || !strings.Contains(result.Warnings[0].Message, "original count 6") || !strings.Contains(result.Warnings[0].Message, "final count 5") || !strings.Contains(result.Warnings[0].Message, "duplicates removed 1") {
if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != ReasonCodeSourceReferencesNormalized || !strings.Contains(result.Warnings[0].Message, "original count 6") || !strings.Contains(result.Warnings[0].Message, "final count 5") || !strings.Contains(result.Warnings[0].Message, "order changed true") || !strings.Contains(result.Warnings[0].Message, "duplicates removed 1") {
t.Fatalf("warnings = %#v, want source normalization warning", result.Warnings)
}
}
func TestNormalizeReportsDuplicateRemovalWithoutOrderChange(t *testing.T) {
ref1 := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}
ref2 := source.SourceRef{SourceID: "source", StartUnitID: 2, EndUnitID: 2}
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Spell: "Cure Wounds",
SourceRefs: []source.SourceRef{ref1, ref1, ref2},
}}}
result, err := newNormalizer(t).Normalize(context.Background(), normalizeRequest(input))
if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err)
}
if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != ReasonCodeSourceReferencesNormalized {
t.Fatalf("warnings = %#v, want one source normalization warning", result.Warnings)
}
message := result.Warnings[0].Message
if !strings.Contains(message, "order changed false") || !strings.Contains(message, "duplicates removed 1") {
t.Fatalf("warning = %q, want duplicate-only repair without order change", message)
}
}
func TestNormalizeOrdersReferencesBySourceDocumentPosition(t *testing.T) {
doc := &source.SourceDocument{ID: "source", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Spell: "Cure Wounds",
SourceRefs: []source.SourceRef{
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
},
}}}
result, err := newNormalizer(t).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err)
}
got := result.Value.SpellCasts[0].SourceRefs
if got[0].StartUnitID != 30 || got[1].StartUnitID != 10 || got[2].StartUnitID != 999 {
t.Fatalf("normalized refs = %#v, want source-document order followed by invalid reference", got)
}
}
func TestNormalizePreservesNilEmptyAndAdjacentOrOverlappingReferences(t *testing.T) {
normalizer := newNormalizer(t)
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{

View File

@@ -3,34 +3,15 @@
package npcinteractions
import (
"sort"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
// CanonicalizeSourceRefs returns a cloned, document-ordered, de-duplicated
// source-reference list.
func CanonicalizeSourceRefs(doc *source.SourceDocument, input []source.SourceRef) []source.SourceRef {
if input == nil {
return nil
}
canonical := append([]source.SourceRef(nil), input...)
sort.SliceStable(canonical, func(left, right int) bool {
return SourceRefLess(doc, canonical[left], canonical[right])
})
unique := make([]source.SourceRef, 0, len(canonical))
for _, ref := range canonical {
if len(unique) == 0 || unique[len(unique)-1] != ref {
unique = append(unique, ref)
}
}
return unique
}
// SourceRefsEqual reports whether two source-reference lists have identical
// representations and values.
func SourceRefsEqual(left, right []source.SourceRef) bool {
@@ -45,39 +26,10 @@ func SourceRefsEqual(left, right []source.SourceRef) bool {
return true
}
// SourceRefLess orders references by source identity and then by the source
// document positions of their endpoints. Invalid endpoints sort after valid
// endpoints and fall back to their literal IDs for deterministic diagnostics.
func SourceRefLess(doc *source.SourceDocument, left, right source.SourceRef) bool {
if left.SourceID != right.SourceID {
return left.SourceID < right.SourceID
}
leftStart, leftStartOK := source.UnitIndex(doc, left.StartUnitID)
rightStart, rightStartOK := source.UnitIndex(doc, right.StartUnitID)
if leftStartOK != rightStartOK {
return leftStartOK
}
if leftStartOK && leftStart != rightStart {
return leftStart < rightStart
}
if left.StartUnitID != right.StartUnitID {
return left.StartUnitID < right.StartUnitID
}
leftEnd, leftEndOK := source.UnitIndex(doc, left.EndUnitID)
rightEnd, rightEndOK := source.UnitIndex(doc, right.EndUnitID)
if leftEndOK != rightEndOK {
return leftEndOK
}
if leftEndOK && leftEnd != rightEnd {
return leftEnd < rightEnd
}
return left.EndUnitID < right.EndUnitID
}
// Less defines the canonical order for NPC interaction occurrences.
func Less(doc *source.SourceDocument, left, right dnd.NPCInteraction) bool {
leftPosition, leftHasEvidence := EarliestSourcePosition(doc, left)
rightPosition, rightHasEvidence := EarliestSourcePosition(doc, right)
func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool {
leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs)
rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs)
if leftHasEvidence != rightHasEvidence {
return leftHasEvidence
}
@@ -95,35 +47,17 @@ func Less(doc *source.SourceDocument, left, right dnd.NPCInteraction) bool {
if left.Kind != right.Kind {
return left.Kind < right.Kind
}
return sourceRefsLess(doc, left.SourceRefs, right.SourceRefs)
}
// EarliestSourcePosition returns the earliest valid cited position.
func EarliestSourcePosition(doc *source.SourceDocument, interaction dnd.NPCInteraction) (int, bool) {
found := false
earliest := 0
for _, ref := range interaction.SourceRefs {
if source.ValidateRef(doc, ref) != nil {
continue
}
position, ok := source.UnitIndex(doc, ref.StartUnitID)
if !ok || (found && position >= earliest) {
continue
}
earliest = position
found = true
}
return earliest, found
return sourceRefsLess(order, left.SourceRefs, right.SourceRefs)
}
// ValidSourceRefs reports whether an interaction has non-empty, valid
// current-document evidence.
func ValidSourceRefs(doc *source.SourceDocument, refs []source.SourceRef) bool {
func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
if len(refs) == 0 {
return false
}
for _, ref := range refs {
if source.ValidateRef(doc, ref) != nil {
if index.ValidateRef(ref) != nil {
return false
}
}
@@ -144,12 +78,12 @@ func ExactIdentity(interaction dnd.NPCInteraction) string {
return key.String()
}
func sourceRefsLess(doc *source.SourceDocument, left, right []source.SourceRef) bool {
func sourceRefsLess(order shared.SourceRefOrder, left, right []source.SourceRef) bool {
for index := 0; index < len(left) && index < len(right); index++ {
if left[index] == right[index] {
continue
}
return SourceRefLess(doc, left[index], right[index])
return order.Less(left[index], right[index])
}
return len(left) < len(right)
}

View File

@@ -7,12 +7,21 @@ import (
func appendSpellLists(values []dnd.SpellList) (dnd.SpellList, error) {
count := 0
present := false
for _, value := range values {
if value.SpellCasts != nil {
present = true
}
count += len(value.SpellCasts)
}
if !present {
return dnd.SpellList{}, nil
}
combined := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, 0, count)}
for _, value := range values {
combined.SpellCasts = append(combined.SpellCasts, value.SpellCasts...)
for _, spellCast := range value.SpellCasts {
combined.SpellCasts = append(combined.SpellCasts, cloneSpellCast(spellCast))
}
}
return combined, nil
}
@@ -31,7 +40,9 @@ func appendNPCLists(values []dnd.NPCList) (dnd.NPCList, error) {
}
combined := dnd.NPCList{NPCs: make([]dnd.NPC, 0, count)}
for _, value := range values {
combined.NPCs = append(combined.NPCs, value.NPCs...)
for _, npc := range value.NPCs {
combined.NPCs = append(combined.NPCs, cloneNPC(npc))
}
}
return combined, nil
}
@@ -105,6 +116,22 @@ func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
return clone
}
func cloneSpellCast(value dnd.SpellCast) dnd.SpellCast {
clone := value
if value.SourceRefs != nil {
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
}
return clone
}
func cloneNPC(value dnd.NPC) dnd.NPC {
clone := value
if value.SourceRefs != nil {
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
}
return clone
}
func cloneNPCInteraction(value dnd.NPCInteraction) dnd.NPCInteraction {
clone := value
if value.SourceRefs != nil {

View File

@@ -285,6 +285,47 @@ func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) {
}
})
}
input := []dnd.NPCList{{NPCs: []dnd.NPC{{Name: "first", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}}}}
merged, err := appendNPCLists(input)
if err != nil {
t.Fatalf("appendNPCLists() error = %v", err)
}
merged.NPCs[0].SourceRefs[0].StartUnitID = 999
if input[0].NPCs[0].SourceRefs[0].StartUnitID == 999 {
t.Fatal("merged NPCs share source reference storage")
}
}
func TestAppendSpellListsPreservesOrderPresenceAndOwnership(t *testing.T) {
tests := []struct {
name string
in []dnd.SpellList
want dnd.SpellList
}{
{name: "no values", in: nil, want: dnd.SpellList{}},
{name: "nil values", in: []dnd.SpellList{{}, {}}, want: dnd.SpellList{}},
{name: "present empty", in: []dnd.SpellList{{SpellCasts: []dnd.SpellCast{}}}, want: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}},
{name: "ordered values", in: []dnd.SpellList{{SpellCasts: []dnd.SpellCast{{Spell: "first"}}}, {SpellCasts: []dnd.SpellCast{{Spell: "second"}}}}, want: dnd.SpellList{SpellCasts: []dnd.SpellCast{{Spell: "first"}, {Spell: "second"}}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := appendSpellLists(tt.in)
if err != nil || !reflect.DeepEqual(got, tt.want) {
t.Fatalf("appendSpellLists() = %#v, error = %v, want %#v", got, err, tt.want)
}
})
}
input := []dnd.SpellList{{SpellCasts: []dnd.SpellCast{{Spell: "Shield", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}}}}
merged, err := appendSpellLists(input)
if err != nil {
t.Fatalf("appendSpellLists() error = %v", err)
}
merged.SpellCasts[0].SourceRefs[0].StartUnitID = 999
if input[0].SpellCasts[0].SourceRefs[0].StartUnitID == 999 {
t.Fatal("merged spell casts share source reference storage")
}
}
func TestAppendNPCInteractionListsPreservesOrderPresenceAndOwnership(t *testing.T) {

View File

@@ -7,27 +7,37 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
// CitationResolver resolves cited ranges against one source document.
type CitationResolver struct {
doc *source.SourceDocument
index source.DocumentIndex
}
// NewCitationResolver prepares citation resolution for doc.
func NewCitationResolver(doc *source.SourceDocument) (*CitationResolver, error) {
if doc == nil {
return nil, fmt.Errorf("source document must not be nil")
}
return &CitationResolver{doc: doc, index: source.NewDocumentIndex(doc)}, nil
}
// CitedText resolves cited source ranges in document order, including each
// source unit once, and joins the resulting text with newlines.
func CitedText(doc *source.SourceDocument, refs []source.SourceRef) (string, error) {
if doc == nil {
return "", fmt.Errorf("source document must not be nil")
}
included := make([]bool, len(doc.Units))
func (r *CitationResolver) CitedText(refs []source.SourceRef) (string, error) {
included := make([]bool, len(r.doc.Units))
for _, ref := range refs {
if err := source.ValidateRef(doc, ref); err != nil {
if err := r.index.ValidateRef(ref); err != nil {
return "", fmt.Errorf("resolve cited source range: %w", err)
}
start, _ := source.UnitIndex(doc, ref.StartUnitID)
end, _ := source.UnitIndex(doc, ref.EndUnitID)
start, _ := r.index.Position(ref.StartUnitID)
end, _ := r.index.Position(ref.EndUnitID)
for index := start; index <= end; index++ {
included[index] = true
}
}
parts := make([]string, 0, len(doc.Units))
for index, unit := range doc.Units {
parts := make([]string, 0, len(r.doc.Units))
for index, unit := range r.doc.Units {
if included[index] {
parts = append(parts, unit.Text)
}

View File

@@ -7,8 +7,12 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
func TestCitedText(t *testing.T) {
func TestCitationResolver(t *testing.T) {
doc := citationDocument()
resolver, err := NewCitationResolver(doc)
if err != nil {
t.Fatalf("NewCitationResolver() error = %v", err)
}
tests := []struct {
name string
refs []source.SourceRef
@@ -51,7 +55,7 @@ func TestCitedText(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
beforeUnits := append([]source.SourceUnit(nil), doc.Units...)
refs := append([]source.SourceRef(nil), test.refs...)
got, err := CitedText(doc, refs)
got, err := resolver.CitedText(refs)
if (err != nil) != test.wantErr {
t.Fatalf("CitedText() error = %v, want error = %t", err, test.wantErr)
}
@@ -65,9 +69,9 @@ func TestCitedText(t *testing.T) {
}
}
func TestCitedTextRejectsNilDocument(t *testing.T) {
if _, err := CitedText(nil, nil); err == nil {
t.Fatal("CitedText() error = nil, want nil-document error")
func TestNewCitationResolverRejectsNilDocument(t *testing.T) {
if _, err := NewCitationResolver(nil); err == nil {
t.Fatal("NewCitationResolver() error = nil, want nil-document error")
}
}

View File

@@ -2,14 +2,30 @@ package shared
import (
"bytes"
"context"
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
// ChunkPromptMaterial prepares the chunk-scoped source material used by D&D
// extractors when constructing their prompt inputs.
func ChunkPromptMaterial(req contracts.TypedExtractionRequest) (contracts.LLMInputMaterial, error) {
// PrepareChunkExtraction validates common D&D extraction prerequisites and
// prepares owned chunk-scoped source material for prompt inputs.
func PrepareChunkExtraction(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.LLMInputMaterial, error) {
if ctx == nil {
return contracts.LLMInputMaterial{}, fmt.Errorf("context must not be nil")
}
if err := ctx.Err(); err != nil {
return contracts.LLMInputMaterial{}, fmt.Errorf("context error before extraction: %w", err)
}
if req.Source == nil {
return contracts.LLMInputMaterial{}, fmt.Errorf("source must not be nil")
}
if req.Chunk == nil {
return contracts.LLMInputMaterial{}, fmt.Errorf("chunk must not be nil")
}
if len(req.Chunk.Units) == 0 {
return contracts.LLMInputMaterial{}, fmt.Errorf("chunk %q units must not be empty", req.Chunk.ID)
}
material := req.SourceInput.Clone()
if len(material.Content) == 0 {
material = contracts.NewLLMInputMaterial("source", req.Chunk.MediaType, req.Chunk.Content, "", "")

View File

@@ -1,6 +1,7 @@
package shared
import (
"context"
"reflect"
"strings"
"testing"
@@ -9,48 +10,78 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
func TestChunkPromptMaterial(t *testing.T) {
chunk := &source.Chunk{
ID: "session-alpha:chunk:0",
Content: []byte(`{"units":[1,2]}`),
MediaType: "application/json",
}
func TestPrepareChunkExtraction(t *testing.T) {
canceled, cancel := context.WithCancel(context.Background())
cancel()
tests := []struct {
name string
ctx context.Context
configure func(*contracts.TypedExtractionRequest)
sourceInput contracts.LLMInputMaterial
want contracts.LLMInputMaterial
wantErr string
mutateOutput bool
}{
{
name: "nil context",
wantErr: "context must not be nil",
},
{
name: "canceled context",
ctx: canceled,
wantErr: "context error before extraction",
},
{
name: "nil source",
configure: func(req *contracts.TypedExtractionRequest) {
req.Source = nil
},
wantErr: "source must not be nil",
},
{
name: "nil chunk",
configure: func(req *contracts.TypedExtractionRequest) {
req.Chunk = nil
},
wantErr: "chunk must not be nil",
},
{
name: "empty chunk units",
configure: func(req *contracts.TypedExtractionRequest) {
req.Chunk = &source.Chunk{ID: req.Chunk.ID}
},
wantErr: "units must not be empty",
},
{
name: "fallback to chunk content",
want: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "", ""),
want: contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"units":[1,2]}`), "", ""),
mutateOutput: true,
},
{
name: "clone isolation",
sourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:source", "file:///source.json"),
want: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:source", "file:///source.json"),
sourceInput: contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"units":[1,2]}`), "sha256:source", "file:///source.json"),
want: contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"units":[1,2]}`), "sha256:source", "file:///source.json"),
mutateOutput: true,
},
{
name: "mismatched content",
sourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, []byte(`{"units":[9]}`), "sha256:other", "file:///other.json"),
sourceInput: contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"units":[9]}`), "sha256:other", "file:///other.json"),
wantErr: "source input must match chunk",
},
{
name: "default fields",
sourceInput: contracts.LLMInputMaterial{
Content: append([]byte(nil), chunk.Content...),
Content: []byte(`{"units":[1,2]}`),
Digest: "sha256:source",
OriginURI: "file:///source.json",
},
want: contracts.LLMInputMaterial{
Name: "source",
MediaType: chunk.MediaType,
Content: append([]byte(nil), chunk.Content...),
MediaType: "application/json",
Content: []byte(`{"units":[1,2]}`),
Digest: "sha256:source",
OriginURI: "file:///source.json",
SizeBytes: int64(len(chunk.Content)),
SizeBytes: int64(len(`{"units":[1,2]}`)),
},
},
{
@@ -58,7 +89,7 @@ func TestChunkPromptMaterial(t *testing.T) {
sourceInput: contracts.LLMInputMaterial{
Name: "transcript",
MediaType: "text/plain",
Content: append([]byte(nil), chunk.Content...),
Content: []byte(`{"units":[1,2]}`),
Digest: "sha256:explicit",
OriginURI: "file:///explicit.txt",
SizeBytes: 42,
@@ -66,7 +97,7 @@ func TestChunkPromptMaterial(t *testing.T) {
want: contracts.LLMInputMaterial{
Name: "transcript",
MediaType: "text/plain",
Content: append([]byte(nil), chunk.Content...),
Content: []byte(`{"units":[1,2]}`),
Digest: "sha256:explicit",
OriginURI: "file:///explicit.txt",
SizeBytes: 42,
@@ -76,26 +107,46 @@ func TestChunkPromptMaterial(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req := contracts.TypedExtractionRequest{Chunk: chunk, SourceInput: test.sourceInput}
got, err := ChunkPromptMaterial(req)
req := extractionRequest()
req.SourceInput = test.sourceInput
if test.configure != nil {
test.configure(&req)
}
ctx := test.ctx
if ctx == nil && test.wantErr != "context must not be nil" {
ctx = context.Background()
}
got, err := PrepareChunkExtraction(ctx, req)
if test.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("ChunkPromptMaterial() error = %v, want %q", err, test.wantErr)
t.Fatalf("PrepareChunkExtraction() error = %v, want %q", err, test.wantErr)
}
return
}
if err != nil {
t.Fatalf("ChunkPromptMaterial() error = %v, want nil", err)
t.Fatalf("PrepareChunkExtraction() error = %v, want nil", err)
}
if !reflect.DeepEqual(got, test.want) {
t.Fatalf("ChunkPromptMaterial() = %#v, want %#v", got, test.want)
t.Fatalf("PrepareChunkExtraction() = %#v, want %#v", got, test.want)
}
if test.mutateOutput {
got.Content[0] = 'x'
if string(test.sourceInput.Content) != string(chunk.Content) {
t.Fatalf("ChunkPromptMaterial() output shares content with source input")
if string(req.SourceInput.Content) != string(test.sourceInput.Content) || string(req.Chunk.Content) != `{"units":[1,2]}` {
t.Fatal("PrepareChunkExtraction() output shares request content")
}
}
})
}
}
func extractionRequest() contracts.TypedExtractionRequest {
doc := &source.SourceDocument{ID: "session-alpha"}
chunk := &source.Chunk{
ID: "session-alpha:chunk:0",
SourceID: doc.ID,
Content: []byte(`{"units":[1,2]}`),
MediaType: "application/json",
Units: []source.SourceUnit{{ID: 1}},
}
return contracts.TypedExtractionRequest{Source: doc, Chunk: chunk}
}

View File

@@ -0,0 +1,101 @@
package shared
import (
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
// SourceRefOrder provides a stable snapshot of a source document's unit
// ordering for source-reference comparison and canonicalization.
type SourceRefOrder struct {
sourceID string
index source.DocumentIndex
}
// NewSourceRefOrder captures the source identity and unit positions from doc.
func NewSourceRefOrder(doc *source.SourceDocument) SourceRefOrder {
return NewSourceRefOrderWithIndex(doc, source.NewDocumentIndex(doc))
}
// NewSourceRefOrderWithIndex captures source identity with a supplied
// document index for source-reference comparison and canonicalization.
func NewSourceRefOrderWithIndex(doc *source.SourceDocument, index source.DocumentIndex) SourceRefOrder {
if doc == nil {
return SourceRefOrder{}
}
return SourceRefOrder{sourceID: doc.ID, index: index}
}
// Less orders references by source identity, then document positions when
// available, and finally literal endpoint IDs.
func (o SourceRefOrder) Less(left, right source.SourceRef) bool {
if left.SourceID != right.SourceID {
return left.SourceID < right.SourceID
}
if o.lessEndpoint(left.SourceID, left.StartUnitID, right.StartUnitID) {
return true
}
if o.lessEndpoint(left.SourceID, right.StartUnitID, left.StartUnitID) {
return false
}
return o.lessEndpoint(left.SourceID, left.EndUnitID, right.EndUnitID)
}
// EarliestValid returns the earliest document position among valid refs.
func (o SourceRefOrder) EarliestValid(refs []source.SourceRef) (int, bool) {
if len(refs) == 0 || o.sourceID == "" {
return 0, false
}
found := false
earliest := 0
for _, ref := range refs {
if o.index.ValidateRef(ref) != nil {
continue
}
start, _ := o.index.Position(ref.StartUnitID)
if !found || start < earliest {
earliest = start
found = true
}
}
return earliest, found
}
// Canonicalize returns an owned, stable-sorted, exactly de-duplicated copy of
// refs. It deliberately preserves invalid references for diagnostics.
func (o SourceRefOrder) Canonicalize(refs []source.SourceRef) []source.SourceRef {
if refs == nil {
return nil
}
canonical := append([]source.SourceRef{}, refs...)
sort.SliceStable(canonical, func(left, right int) bool {
return o.Less(canonical[left], canonical[right])
})
unique := make([]source.SourceRef, 0, len(canonical))
for _, ref := range canonical {
if len(unique) == 0 || unique[len(unique)-1] != ref {
unique = append(unique, ref)
}
}
return unique
}
func (o SourceRefOrder) lessEndpoint(sourceID string, left, right int) bool {
leftPosition, leftOK := o.position(sourceID, left)
rightPosition, rightOK := o.position(sourceID, right)
if leftOK != rightOK {
return leftOK
}
if leftOK && leftPosition != rightPosition {
return leftPosition < rightPosition
}
return left < right
}
func (o SourceRefOrder) position(sourceID string, unitID int) (int, bool) {
if o.sourceID == "" || sourceID != o.sourceID {
return 0, false
}
return o.index.Position(unitID)
}

View File

@@ -0,0 +1,81 @@
package shared
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
func TestSourceRefOrderUsesDocumentOrderAndLiteralFallbacks(t *testing.T) {
doc := unitRefSourceDocument(30, 10, 20)
order := NewSourceRefOrder(doc)
refs := []source.SourceRef{
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
{SourceID: "other", StartUnitID: 30, EndUnitID: 1},
{SourceID: "other", StartUnitID: 10, EndUnitID: 2},
}
got := order.Canonicalize(refs)
want := []source.SourceRef{
{SourceID: "other", StartUnitID: 10, EndUnitID: 2},
{SourceID: "other", StartUnitID: 30, EndUnitID: 1},
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Canonicalize() = %#v, want %#v", got, want)
}
if order.Less(want[0], want[0]) {
t.Fatal("Less(ref, ref) = true, want false")
}
}
func TestSourceRefOrderCanonicalizePreservesRepresentationAndOwnership(t *testing.T) {
order := NewSourceRefOrder(unitRefSourceDocument(3, 1, 2))
if got := order.Canonicalize(nil); got != nil {
t.Fatalf("Canonicalize(nil) = %#v, want nil", got)
}
empty := []source.SourceRef{}
if got := order.Canonicalize(empty); got == nil || len(got) != 0 {
t.Fatalf("Canonicalize(empty) = %#v, want owned empty slice", got)
}
input := []source.SourceRef{
{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2},
{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2},
{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 3},
}
got := order.Canonicalize(input)
if len(got) != 2 || got[0] == got[1] {
t.Fatalf("Canonicalize() = %#v, want exact duplicate removed but distinct range retained", got)
}
got[0].StartUnitID = 99
if input[0].StartUnitID == 99 {
t.Fatal("Canonicalize() output aliases input")
}
}
func TestSourceRefOrderSnapshotAndEarliestValid(t *testing.T) {
doc := unitRefSourceDocument(30, 10, 20)
order := NewSourceRefOrder(doc)
doc.Units[0].ID, doc.Units[1].ID = 10, 30
refs := []source.SourceRef{
{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20},
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
{SourceID: "other", StartUnitID: 1, EndUnitID: 1},
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 30},
}
if position, ok := order.EarliestValid(refs); !ok || position != 1 {
t.Fatalf("EarliestValid() = %d, %t, want 1, true", position, ok)
}
if position, ok := order.EarliestValid([]source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 30}}); ok || position != 0 {
t.Fatalf("EarliestValid(invalid) = %d, %t, want 0, false", position, ok)
}
if position, ok := (SourceRefOrder{}).EarliestValid(refs); ok || position != 0 {
t.Fatalf("zero EarliestValid() = %d, %t, want 0, false", position, ok)
}
}

View File

@@ -77,28 +77,16 @@ func (ref UnitRef) MarshalJSON() ([]byte, error) {
return json.Marshal(ref.String())
}
func ResolveUnitID(doc *source.SourceDocument, field string, ref UnitRef) (int, error) {
func ResolveUnitID(index source.DocumentIndex, field string, ref UnitRef) (int, error) {
if ref.value <= 0 {
return 0, fmt.Errorf("%s must be positive", field)
}
if _, ok := source.UnitIndex(doc, ref.value); !ok {
if _, ok := index.Position(ref.value); !ok {
return 0, fmt.Errorf("%s %d was not found", field, ref.value)
}
return ref.value, nil
}
func SourceRefCandidate(doc *source.SourceDocument, ref SourceRefResponse) source.SourceRef {
return source.SourceRef{
SourceID: strings.TrimSpace(ref.SourceID),
StartUnitID: unitIDCandidate(ref.StartUnitID),
EndUnitID: unitIDCandidate(ref.EndUnitID),
}
}
func unitIDCandidate(ref UnitRef) int {
return ref.value
}
func parseUnitRefNumber(value string) (int, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {

View File

@@ -44,7 +44,7 @@ func TestUnitRefUnmarshalRejectsNonIntegerValues(t *testing.T) {
func TestResolveUnitIDReturnsExistingIntegerSourceUnitID(t *testing.T) {
doc := unitRefSourceDocument(2, 10)
got, err := ResolveUnitID(doc, "start_unit_id", UnitRefFromInt(2))
got, err := ResolveUnitID(source.NewDocumentIndex(doc), "start_unit_id", UnitRefFromInt(2))
if err != nil {
t.Fatalf("ResolveUnitID() error = %v, want nil", err)
}
@@ -56,7 +56,7 @@ func TestResolveUnitIDReturnsExistingIntegerSourceUnitID(t *testing.T) {
func TestResolveUnitIDDoesNotFallbackToOneBasedUnitNumber(t *testing.T) {
doc := unitRefSourceDocument(10, 20)
_, err := ResolveUnitID(doc, "end_unit_id", UnitRefFromInt(2))
_, err := ResolveUnitID(source.NewDocumentIndex(doc), "end_unit_id", UnitRefFromInt(2))
if err == nil {
t.Fatal("ResolveUnitID() error = nil, want missing source-unit ID")
}
@@ -65,7 +65,7 @@ func TestResolveUnitIDDoesNotFallbackToOneBasedUnitNumber(t *testing.T) {
func TestResolveUnitIDRejectsMissingUnit(t *testing.T) {
doc := unitRefSourceDocument(1)
_, err := ResolveUnitID(doc, "start_unit_id", UnitRefFromInt(9))
_, err := ResolveUnitID(source.NewDocumentIndex(doc), "start_unit_id", UnitRefFromInt(9))
if err == nil {
t.Fatal("ResolveUnitID() error = nil, want error")
}
@@ -74,28 +74,6 @@ func TestResolveUnitIDRejectsMissingUnit(t *testing.T) {
}
}
func TestSourceRefCandidateCanonicalizesValidRefsAndPreservesInvalidRefs(t *testing.T) {
doc := unitRefSourceDocument(1, 2)
valid := SourceRefCandidate(doc, SourceRefResponse{
SourceID: " session-alpha ",
StartUnitID: UnitRefFromInt(1),
EndUnitID: UnitRefFromInt(2),
})
if valid != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}) {
t.Fatalf("valid candidate = %#v, want canonical source ref", valid)
}
invalid := SourceRefCandidate(doc, SourceRefResponse{
SourceID: "session-alpha",
StartUnitID: UnitRefFromInt(9),
EndUnitID: UnitRefFromString("missing"),
})
if invalid != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 9, EndUnitID: 0}) {
t.Fatalf("invalid candidate = %#v, want unresolved values for validator", invalid)
}
}
func unitRefSourceDocument(ids ...int) *source.SourceDocument {
doc := &source.SourceDocument{
ID: "session-alpha",

View File

@@ -12,6 +12,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
)
@@ -19,7 +20,7 @@ import (
const (
Key = "normalize/dnd/combat-turns/invariants"
ReasonCode = "invalid_combat_turn_normalization"
policy = "dnd.combat_turns.validator.normalized.v1"
policy = "dnd.combat_turns.validator.normalized.v2"
)
type Options struct{}
@@ -47,17 +48,21 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
// Validate checks only invariants owned by normalized combat-turn output. A
// shape or source-reference failure is deliberately deferred to its owner.
func Validate(doc *source.SourceDocument, value dnd.CombatTurnList) error {
if combatshape.Validate(value) != nil || !sourceRefsValid(doc, value) {
if combatshape.Validate(value) != nil {
return nil
}
issues := issuesFor(doc, value)
index := source.NewDocumentIndex(doc)
if !sourceRefsValid(index, value) {
return nil
}
issues := issuesFor(shared.NewSourceRefOrderWithIndex(doc, index), value)
if len(issues) == 0 {
return nil
}
return fmt.Errorf("%s", diagnostics.Aggregate("invalid combat turn normalization", issues))
}
func issuesFor(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
func issuesFor(order shared.SourceRefOrder, value dnd.CombatTurnList) []string {
issues := make([]string, 0)
seenIdentity := make(map[string]int)
previousPosition := -1
@@ -69,14 +74,14 @@ func issuesFor(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
for refIndex := 1; refIndex < len(turn.SourceRefs); refIndex++ {
previous := turn.SourceRefs[refIndex-1]
current := turn.SourceRefs[refIndex]
if sourceRefLess(current, previous) {
if order.Less(current, previous) {
issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex))
} else if current == previous {
issues = append(issues, fmt.Sprintf("%s.source_refs[%d] duplicates the previous reference", prefix, refIndex))
}
}
position, ok := earliestSourcePosition(doc, turn)
position, ok := order.EarliestValid(turn.SourceRefs)
if !ok {
continue
}
@@ -96,10 +101,10 @@ func issuesFor(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
return issues
}
func sourceRefsValid(doc *source.SourceDocument, value dnd.CombatTurnList) bool {
func sourceRefsValid(index source.DocumentIndex, value dnd.CombatTurnList) bool {
for _, turn := range value.CombatTurns {
for _, ref := range turn.SourceRefs {
if source.ValidateRef(doc, ref) != nil {
if index.ValidateRef(ref) != nil {
return false
}
}
@@ -107,36 +112,6 @@ func sourceRefsValid(doc *source.SourceDocument, value dnd.CombatTurnList) bool
return true
}
func sourceRefLess(left, right source.SourceRef) bool {
if left.SourceID != right.SourceID {
return left.SourceID < right.SourceID
}
if left.StartUnitID != right.StartUnitID {
return left.StartUnitID < right.StartUnitID
}
return left.EndUnitID < right.EndUnitID
}
func earliestSourcePosition(doc *source.SourceDocument, turn dnd.CombatTurn) (int, bool) {
if doc == nil {
return 0, false
}
earliest := 0
found := false
for _, ref := range turn.SourceRefs {
if source.ValidateRef(doc, ref) != nil {
continue
}
index, ok := source.UnitIndex(doc, ref.StartUnitID)
if !ok || (found && index >= earliest) {
continue
}
earliest = index
found = true
}
return earliest, found
}
func duplicateKey(turn dnd.CombatTurn) (string, bool) {
if len(turn.SourceRefs) == 0 {
return "", false

View File

@@ -57,6 +57,24 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
}
}
func TestValidateUsesSourceDocumentOrder(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
{Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}}
if err := Validate(doc, value); err == nil || !strings.Contains(err.Error(), "not in canonical order") {
t.Fatalf("Validate() error = %v, want document-order reference rejection", err)
}
value = dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
{Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
{Actor: "Borin", TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}}
if err := Validate(doc, value); err == nil || !strings.Contains(err.Error(), "out of chronological order") {
t.Fatalf("Validate() error = %v, want document-order chronology rejection", err)
}
}
func TestValidatorDefersShapeAndSourceReferenceFailures(t *testing.T) {
doc := invariantDocument()
shapeInvalid := normalizedList()

View File

@@ -37,7 +37,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if err := combatshape.Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
issues := sourceRefIssues(req.Source, req.Value)
issues := sourceRefIssues(source.NewDocumentIndex(req.Source), req.Value)
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
@@ -48,11 +48,11 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
}, nil
}
func sourceRefIssues(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
func sourceRefIssues(index source.DocumentIndex, value dnd.CombatTurnList) []string {
issues := make([]string, 0)
for turnIndex, turn := range value.CombatTurns {
for refIndex, ref := range turn.SourceRefs {
if err := source.ValidateRef(doc, ref); err != nil {
if err := index.ValidateRef(ref); err != nil {
issues = append(issues, fmt.Sprintf("combat_turns[%d].source_refs[%d]: %s", turnIndex, refIndex, diagnostics.Truncate(err.Error())))
}
}

View File

@@ -37,10 +37,14 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if combatshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil
}
resolver, err := shared.NewCitationResolver(req.Source)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
citedTexts := make([]string, len(req.Value.CombatTurns))
for turnIndex, turn := range req.Value.CombatTurns {
citedText, err := shared.CitedText(req.Source, turn.SourceRefs)
citedText, err := resolver.CitedText(turn.SourceRefs)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}

View File

@@ -12,6 +12,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
interactionmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcinteractions"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape"
)
@@ -76,7 +77,11 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) {
if interactionshape.Validate(req.Value) != nil || !allSourceRefsValid(req.Source, req.Value) {
if interactionshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil
}
index := source.NewDocumentIndex(req.Source)
if !allSourceRefsValid(index, req.Value) {
return contracts.ValidationResult{Approved: true}, nil
}
if v == nil || v.npcResolver == nil {
@@ -89,7 +94,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if !npcRegistry.Bound() {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: "invalid NPC interaction normalization: NPC registry reference is required"}, nil
}
issues := issuesFor(req.Source, req.Value, npcRegistry)
issues := issuesFor(shared.NewSourceRefOrderWithIndex(req.Source, index), req.Value, npcRegistry)
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
@@ -100,16 +105,16 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
}, nil
}
func allSourceRefsValid(doc *source.SourceDocument, value dnd.NPCInteractionList) bool {
func allSourceRefsValid(index source.DocumentIndex, value dnd.NPCInteractionList) bool {
for _, interaction := range value.Interactions {
if !interactionmodel.ValidSourceRefs(doc, interaction.SourceRefs) {
if !interactionmodel.ValidSourceRefs(index, interaction.SourceRefs) {
return false
}
}
return true
}
func issuesFor(doc *source.SourceDocument, value dnd.NPCInteractionList, npcRegistry *npcregistry.Registry) []string {
func issuesFor(order shared.SourceRefOrder, value dnd.NPCInteractionList, npcRegistry *npcregistry.Registry) []string {
issues := make([]string, 0)
for index, interaction := range value.Interactions {
prefix := fmt.Sprintf("interactions[%d]", index)
@@ -119,7 +124,7 @@ func issuesFor(doc *source.SourceDocument, value dnd.NPCInteractionList, npcRegi
for refIndex := 1; refIndex < len(interaction.SourceRefs); refIndex++ {
previous := interaction.SourceRefs[refIndex-1]
current := interaction.SourceRefs[refIndex]
if interactionmodel.SourceRefLess(doc, current, previous) {
if order.Less(current, previous) {
issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex))
} else if current == previous {
issues = append(issues, fmt.Sprintf("%s.source_refs[%d] duplicates the previous reference", prefix, refIndex))
@@ -128,7 +133,7 @@ func issuesFor(doc *source.SourceDocument, value dnd.NPCInteractionList, npcRegi
}
if !sort.SliceIsSorted(value.Interactions, func(left, right int) bool {
return interactionmodel.Less(doc, value.Interactions[left], value.Interactions[right])
return interactionmodel.Less(order, value.Interactions[left], value.Interactions[right])
}) {
issues = append(issues, "interactions are not in canonical order")
}

View File

@@ -41,10 +41,11 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if interactionshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil
}
index := source.NewDocumentIndex(req.Source)
issues := make([]string, 0)
for interactionIndex, interaction := range req.Value.Interactions {
for refIndex, ref := range interaction.SourceRefs {
if err := source.ValidateRef(req.Source, ref); err != nil {
if err := index.ValidateRef(ref); err != nil {
issues = append(issues, fmt.Sprintf("interactions[%d].source_refs[%d]: %s", interactionIndex, refIndex, diagnostics.Truncate(err.Error())))
continue
}

View File

@@ -39,9 +39,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if interactionshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil
}
resolver, err := shared.NewCitationResolver(req.Source)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
citedTexts := make([]string, len(req.Value.Interactions))
for index, interaction := range req.Value.Interactions {
citedText, err := shared.CitedText(req.Source, interaction.SourceRefs)
citedText, err := resolver.CitedText(interaction.SourceRefs)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}

View File

@@ -37,10 +37,11 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if err := npcshape.Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
index := source.NewDocumentIndex(req.Source)
issues := make([]string, 0)
for npcIndex, npc := range req.Value.NPCs {
for refIndex, ref := range npc.SourceRefs {
if err := source.ValidateRef(req.Source, ref); err != nil {
if err := index.ValidateRef(ref); err != nil {
issues = append(issues, fmt.Sprintf("npcs[%d].source_refs[%d]: %s", npcIndex, refIndex, diagnostics.Truncate(err.Error())))
}
}

View File

@@ -37,9 +37,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if err := npcshape.Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
resolver, err := shared.NewCitationResolver(req.Source)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
citedTexts := make([]string, len(req.Value.NPCs))
for npcIndex, npc := range req.Value.NPCs {
citedText, err := shared.CitedText(req.Source, npc.SourceRefs)
citedText, err := resolver.CitedText(npc.SourceRefs)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}

View File

@@ -36,10 +36,14 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SceneDescriptionList]) (contracts.ValidationResult, error) {
if shape.Validate(req.Value) != nil || !sourceRefsValid(req.Source, req.Value) {
if shape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil
}
issues := issuesFor(req.Source, req.Value)
index := source.NewDocumentIndex(req.Source)
if !sourceRefsValid(index, req.Value) {
return contracts.ValidationResult{Approved: true}, nil
}
issues := issuesFor(index, req.Value)
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
@@ -49,20 +53,20 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
}, nil
}
func sourceRefsValid(doc *source.SourceDocument, value dnd.SceneDescriptionList) bool {
func sourceRefsValid(index source.DocumentIndex, value dnd.SceneDescriptionList) bool {
for _, scene := range value.Scenes {
if source.ValidateRef(doc, scene.SourceRef) != nil {
if index.ValidateRef(scene.SourceRef) != nil {
return false
}
}
return true
}
func issuesFor(doc *source.SourceDocument, value dnd.SceneDescriptionList) []string {
func issuesFor(index source.DocumentIndex, value dnd.SceneDescriptionList) []string {
issues := make([]string, 0)
if !sort.SliceIsSorted(value.Scenes, func(left, right int) bool {
leftStart, _ := source.UnitIndex(doc, value.Scenes[left].SourceRef.StartUnitID)
rightStart, _ := source.UnitIndex(doc, value.Scenes[right].SourceRef.StartUnitID)
leftStart, _ := index.Position(value.Scenes[left].SourceRef.StartUnitID)
rightStart, _ := index.Position(value.Scenes[right].SourceRef.StartUnitID)
if leftStart != rightStart {
return leftStart < rightStart
}

View File

@@ -27,6 +27,10 @@ func TestValidatorRequiresSceneShapeAndExactlyOneExtractionRecord(t *testing.T)
{name: "empty list", value: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{}}},
{name: "blank ID", value: list(scene(" ", dnd.SceneKindNarrative, "Arrival", "The party arrives.", 1, 1))},
{name: "unsupported kind", value: list(scene("one", "other", "Arrival", "The party arrives.", 1, 1))},
{name: "empty title", value: list(scene("one", dnd.SceneKindNarrative, "", "The party arrives.", 1, 1))},
{name: "whitespace-only title", value: list(scene("one", dnd.SceneKindNarrative, " ", "The party arrives.", 1, 1))},
{name: "empty summary", value: list(scene("one", dnd.SceneKindNarrative, "Arrival", "", 1, 1))},
{name: "whitespace-only summary", value: list(scene("one", dnd.SceneKindNarrative, "Arrival", " ", 1, 1))},
{name: "untrimmed prose", value: list(scene("one", dnd.SceneKindNarrative, " Arrival ", "The party arrives.", 1, 1))},
{name: "empty source reference", value: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{ID: "one", Kind: dnd.SceneKindNarrative, Title: "Arrival", Summary: "The party arrives."}}}},
} {

View File

@@ -42,9 +42,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil {
issues = append(issues, "current extraction chunk must not be nil")
}
for index, scene := range req.Value.Scenes {
prefix := fmt.Sprintf("scenes[%d]", index)
if err := source.ValidateRef(req.Source, scene.SourceRef); err != nil {
index := source.NewDocumentIndex(req.Source)
for sceneIndex, scene := range req.Value.Scenes {
prefix := fmt.Sprintf("scenes[%d]", sceneIndex)
if err := index.ValidateRef(scene.SourceRef); err != nil {
issues = append(issues, prefix+".source_ref: "+diagnostics.Truncate(err.Error()))
continue
}

View File

@@ -48,9 +48,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if shape.Validate(req.Value) != nil || !sourceRefsValid(req.Source, req.Value) {
return contracts.ValidationResult{Approved: true}, nil
}
resolver, err := shared.NewCitationResolver(req.Source)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
warnings := make([]contracts.Warning, 0)
for index, scene := range req.Value.Scenes {
citedText, err := shared.CitedText(req.Source, []source.SourceRef{scene.SourceRef})
citedText, err := resolver.CitedText([]source.SourceRef{scene.SourceRef})
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}

View File

@@ -36,10 +36,11 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if err := spellshape.Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
index := source.NewDocumentIndex(req.Source)
issues := make([]string, 0)
for spellIndex, spell := range req.Value.SpellCasts {
for refIndex, ref := range spell.SourceRefs {
if err := source.ValidateRef(req.Source, ref); err != nil {
if err := index.ValidateRef(ref); err != nil {
issues = append(issues, fmt.Sprintf("spell_casts[%d].source_refs[%d]: %s", spellIndex, refIndex, diagnostics.Truncate(err.Error())))
}
}

View File

@@ -36,9 +36,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if err := spellshape.Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
resolver, err := shared.NewCitationResolver(req.Source)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
citedTexts := make([]string, len(req.Value.SpellCasts))
for spellIndex, spell := range req.Value.SpellCasts {
citedText, err := shared.CitedText(req.Source, spell.SourceRefs)
citedText, err := resolver.CitedText(spell.SourceRefs)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}

View File

@@ -40,6 +40,12 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
if err != nil {
t.Fatalf("Prepare() error = %v", err)
}
for name, value := range map[string]string{
"extract:npcs:dnd/npcs:mapping_policy": "dnd.npcs.extract_mapping.v2",
"extract:spells:dnd/spells:mapping_policy": "dnd.spells.extract_mapping.v2",
} {
assertFingerprintValue(t, prepared.CheckpointFingerprints(), name, value)
}
for _, name := range []string{
"extract:npcs:dnd/npcs:prompt",
"extract:npcs:dnd/npcs:response_schema",
@@ -173,6 +179,16 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
assertCurrentEvidence(t, combatValue.CombatTurns[0].SourceRefs)
}
func assertFingerprintValue(t *testing.T, fingerprints []pipeline.CheckpointFingerprint, name, want string) {
t.Helper()
for _, fingerprint := range fingerprints {
if fingerprint.Name == name && fingerprint.Value == want {
return
}
}
t.Fatalf("fingerprints = %#v, want %q = %q", fingerprints, name, want)
}
func assertCurrentEvidence(t *testing.T, references []source.SourceRef) {
t.Helper()
for _, reference := range references {

View File

@@ -95,7 +95,7 @@ func TestRunnerProcessesSeriatimInputWithProductionDNDNPCPipeline(t *testing.T)
t.Fatalf("manifest lane = %#v, want NPC production composition", lane)
}
normalizerMetadata, ok := lane.Metadata["normalizer"].(map[string]any)
if !ok || normalizerMetadata["identity_policy"] != identity.Policy || normalizerMetadata["normalization_policy"] != "dnd.npcs.normalize.v1" {
if !ok || normalizerMetadata["identity_policy"] != identity.Policy || normalizerMetadata["normalization_policy"] != "dnd.npcs.normalize.v2" {
t.Fatalf("normalizer metadata = %#v, want identity and normalization policies", lane.Metadata)
}
var npcOutputFile *contracts.OutputFile