Compare commits

...

14 Commits

57 changed files with 5295 additions and 77 deletions

View File

@@ -3,7 +3,7 @@
Notarius is a Go CLI for turning source material into structured artifacts with
configured extraction pipelines. The implemented D&D workflow reads Seriatim
transcript JSON and can produce scene descriptions, item and currency events,
NPC identities, combat turns, NPC interactions, and spell casts.
NPC identities, combat turns, NPC interactions, enemy events, and spell casts.
## Quickstart

View File

@@ -370,14 +370,28 @@ selected target declares them:
| **players** | Optional text player context. |
| **glossary** | Optional text campaign glossary. |
| **spell_catalog** | Optional JSON spell-catalog overlay for spell extraction and normalization. See [spell-catalog overlays](integrations/dnd-spell-catalog-overlays.md). |
| **npcs** | Normalized NPC registry. Optional for spells and combat turns; required for NPC interactions. |
| **scene_descriptions** | Required normalized scene-description artifact for combat-turn extraction. |
| **npcs** | Normalized NPC registry. Optional for spells and combat turns; required for NPC interactions and enemy-event extraction and normalization. |
| **scene_descriptions** | Required normalized scene-description artifact for combat-turn and enemy-event extraction. |
| **combat_turns** | Required normalized combat-turn artifact for enemy-event extraction. |
| **npc_interactions** | Required normalized NPC-interaction artifact for enemy-event extraction. |
Enemy-event artifact slots have the following exact binding contract. Durable
event semantics and wire shape remain in the
[enemy-event artifact contract](integrations/dnd-enemy-event-artifacts.md).
| Slot | Accepted artifact kind | Media type | Maximum size | Required stage |
| --- | --- | --- | --- | --- |
| `npcs` | `dnd/npc-list` | `application/json` | 1,048,576 bytes | extract and normalize |
| `scene_descriptions` | `dnd/scene-description-list` | `application/json` | 1,048,576 bytes | extract only |
| `combat_turns` | `dnd/combat-turn-list` | `application/json` | 1,048,576 bytes | extract only |
| `npc_interactions` | `dnd/npc-interaction-list` | `application/json` | 1,048,576 bytes | extract only |
Scene descriptions accept **party**, **players**, and **glossary**, but not
**roster**. NPC interactions require **npcs** for both extraction and
normalization. Combat turns require **scene_descriptions** for extraction; the
normalized combat-turn module may use optional **npcs**. The complete example
shows generated **npcs** and **scene_descriptions** bindings.
normalized combat-turn module may use optional **npcs**. Enemy-event extraction
requires all four JSON artifact slots; its normalizer requires **npcs**. The
complete example shows the ordered generated bindings.
## Production Module Keys
@@ -385,9 +399,9 @@ shows generated **npcs** and **scene_descriptions** bindings.
| --- | --- |
| Input | **seriatim** |
| Chunk | **generic**, **dnd/scenes** |
| Extract | **dnd/spells**, **dnd/npcs**, **dnd/combat-turns**, **dnd/item-events**, **dnd/npc-interactions**, **dnd/scene-descriptions** |
| Extract | **dnd/spells**, **dnd/npcs**, **dnd/combat-turns**, **dnd/item-events**, **dnd/npc-interactions**, **dnd/scene-descriptions**, **dnd/enemy-events** |
| Merge | **appendorder** |
| Normalize | **noop**, **dnd/spells**, **dnd/npcs**, **dnd/combat-turns**, **dnd/item-events**, **dnd/npc-interactions**, **dnd/scene-descriptions** |
| Normalize | **noop**, **dnd/spells**, **dnd/npcs**, **dnd/combat-turns**, **dnd/item-events**, **dnd/npc-interactions**, **dnd/scene-descriptions**, **dnd/enemy-events** |
| Output | **json** |
The D&D artifact contracts define each emitted schema:
@@ -395,8 +409,9 @@ The D&D artifact contracts define each emitted schema:
[NPCs](integrations/dnd-npc-artifacts.md),
[NPC interactions](integrations/dnd-npc-interaction-artifacts.md),
[combat turns](integrations/dnd-combat-turn-artifacts.md),
[item events](integrations/dnd-item-event-artifacts.md), and
[scene descriptions](integrations/dnd-scene-description-artifacts.md).
[item events](integrations/dnd-item-event-artifacts.md),
[scene descriptions](integrations/dnd-scene-description-artifacts.md), and
[enemy events](integrations/dnd-enemy-event-artifacts.md).
## Production Validator Keys And Default Chains
@@ -411,6 +426,7 @@ Available validator keys are:
| Item events | **extract/dnd/item-events/shape**, **extract/dnd/item-events/source_refs**, **extract/dnd/item-events/source_relatedness**, **normalize/dnd/item-events/invariants** |
| NPC interactions | **extract/dnd/npc-interactions/shape**, **extract/dnd/npc-interactions/registry**, **extract/dnd/npc-interactions/source_refs**, **extract/dnd/npc-interactions/source_relatedness**, **normalize/dnd/npc-interactions/invariants** |
| Scene descriptions | **extract/dnd/scene-descriptions/shape**, **extract/dnd/scene-descriptions/source_refs**, **extract/dnd/scene-descriptions/source_relatedness**, **normalize/dnd/scene-descriptions/invariants** |
| Enemy events | **extract/dnd/enemy-events/shape**, **extract/dnd/enemy-events/engagements**, **extract/dnd/enemy-events/source_refs**, **extract/dnd/enemy-events/source_relatedness**, **normalize/dnd/enemy-events/invariants** |
When no override is configured, production D&D bindings use the following
ordered chains. Each row lists extract then normalize; spell chains are the
@@ -424,6 +440,7 @@ same at both stages.
| Item events | generic/valid_json, extract/dnd/item-events/shape, extract/dnd/item-events/source_refs, generic/valid_json_schema, extract/dnd/item-events/source_relatedness | generic/valid_json, extract/dnd/item-events/shape, normalize/dnd/item-events/invariants, extract/dnd/item-events/source_refs, generic/valid_json_schema, extract/dnd/item-events/source_relatedness |
| NPC interactions | generic/valid_json, extract/dnd/npc-interactions/shape, extract/dnd/npc-interactions/registry, extract/dnd/npc-interactions/source_refs, generic/valid_json_schema, extract/dnd/npc-interactions/source_relatedness | generic/valid_json, extract/dnd/npc-interactions/shape, extract/dnd/npc-interactions/registry, normalize/dnd/npc-interactions/invariants, extract/dnd/npc-interactions/source_refs, generic/valid_json_schema, extract/dnd/npc-interactions/source_relatedness |
| Scene descriptions | generic/valid_json, extract/dnd/scene-descriptions/shape, extract/dnd/scene-descriptions/source_refs, generic/valid_json_schema, extract/dnd/scene-descriptions/source_relatedness | generic/valid_json, extract/dnd/scene-descriptions/shape, normalize/dnd/scene-descriptions/invariants, extract/dnd/scene-descriptions/source_refs, generic/valid_json_schema, extract/dnd/scene-descriptions/source_relatedness |
| Enemy events | generic/valid_json, extract/dnd/enemy-events/shape, extract/dnd/enemy-events/engagements, extract/dnd/enemy-events/source_refs, generic/valid_json_schema, extract/dnd/enemy-events/source_relatedness | generic/valid_json, extract/dnd/enemy-events/shape, normalize/dnd/enemy-events/invariants, extract/dnd/enemy-events/source_refs, generic/valid_json_schema, extract/dnd/enemy-events/source_relatedness |
Chains are only registered for the D&D extract and normalize modules shown
above; select an explicit override when a different compatible chain is

View File

@@ -64,6 +64,8 @@ kind, and complete valid evidence. It does not infer turns, initiative, or
actions from registry or scene data.
The [NPC-interaction artifact](dnd-npc-interaction-artifacts.md) records
broader NPC occurrences. The [JSON output contract](json-output.md) defines
publication, and [D&D module internals](../internal/dnd.md) describes routing
and validation mechanics.
broader NPC occurrences. The [enemy-event artifact](dnd-enemy-event-artifacts.md)
uses combat turns as grounding only; turns do not establish an enemy event or
its outcome. The [JSON output contract](json-output.md) defines publication,
and [D&D module internals](../internal/dnd.md) describes routing and validation
mechanics.

View File

@@ -0,0 +1,114 @@
# D&D Enemy-Event Artifact
This contract defines the durable, source-grounded enemy-event occurrence list.
It records enemies directly established as opposing the party and explicitly
observed combat outcomes. It is an ordered observation artifact from which a
consumer may derive a ledger; it is not a ledger, encounter roster, or terminal
state model.
## Identity and compatibility
| Property | Value |
| --- | --- |
| Artifact kind | `dnd/enemy-event-list` |
| Schema ID | `notarius.dnd.enemy_events` |
| Schema name | `notarius_dnd_enemy_events_v1` |
| Schema version | `v1` |
| Media type | `application/json` |
`v1` is a strict JSON object with required `events`; the array may be empty.
Event and source-reference objects reject unknown fields. An incompatible shape
change requires a new schema version.
## Wire shape
Every event has these required fields:
| Field | Contract |
| --- | --- |
| `name` | Non-empty display name or directly grounded collective subject label. |
| `kind` | `engaged`, `killed`, `fled`, `captured`, or `incapacitated`. |
| `source_refs` | One or more current-transcript evidence ranges. |
Each source reference has exactly `source_id`, `start_unit_id`, and
`end_unit_id`. It identifies an inclusive current-transcript range; unit IDs
are positive and the start may not follow the end.
```json
{
"events": [
{
"name": "Ashfang",
"kind": "engaged",
"source_refs": [
{"source_id": "session-7", "start_unit_id": 41, "end_unit_id": 42}
]
},
{
"name": "Ashfang",
"kind": "fled",
"source_refs": [
{"source_id": "session-7", "start_unit_id": 57, "end_unit_id": 58}
]
}
]
}
```
## Event semantics and evidence
| Kind | Required evidence |
| --- | --- |
| `engaged` | The subject is directly established as actively opposing the party in combat. At most one engagement is emitted for one subject in one combat scene. |
| `killed` | The transcript explicitly establishes that the subject died or was killed. Damage, defeat, disappearance, or combat ending is insufficient. |
| `fled` | The subject explicitly escapes, retreats, or otherwise leaves combat to avoid continued engagement. Movement or absence from later turns is insufficient. |
| `captured` | The subject is explicitly taken prisoner or secured under the party's control. A grapple or temporary restraint alone is insufficient. |
| `incapacitated` | The subject is explicitly rendered unable to continue acting without being established as killed or captured. A missed turn is insufficient. |
The current transcript is the only event evidence. Campaign context and
normalized NPC, scene-description, combat-turn, and NPC-interaction artifacts
can ground names or control combat eligibility, but none may supply event
evidence. An outcome may share evidence with an engagement, in which case both
events are retained.
Extraction is limited to chunks with an exact combat-scene classification. An
exact non-combat classification produces an accepted empty list. Missing or
mismatched classification also produces an accepted empty list and a
`scene_classification_unavailable` warning.
## Subjects, normalization, and order
A subject matching the normalized NPC registry uses that registry's canonical
display name. Unmatched hostile creatures, summoned entities, and directly
grounded groups remain valid subjects. An unnamed homogeneous group uses the
narrowest transcript-grounded label, such as `Orcs`, `One orc`, or `Remaining
orcs`; the artifact never invents synthetic member identities or quantities.
Party members, allies, neutral observers, mentioned-but-absent enemies, hazards,
traps, and environmental effects are excluded.
Normalization collapses surrounding and repeated internal whitespace in subject
display values, canonicalizes recognized registry names, canonicalizes and
deduplicates exact source ranges, then orders events by valid evidence
chronology, normalized subject identity, display name, kind, and reference
sequence. The deterministic kind tie order is `engaged`,
`incapacitated`, `captured`, `fled`, then `killed`. Only entries with the same
normalized name, kind, and complete canonical evidence sequence are collapsed.
Different kinds, evidence, repeated engagement in separate scenes, and later
outcomes remain separate. A later engagement for the same named subject is
preserved after an earlier outcome because the artifact does not assert an
irreversible state transition.
## Non-goals
The artifact has no NPC or scene ID, quantity, confidence, description,
rationale, summary, current state, or inferred terminal outcome. It does not
emit `active` or `unresolved`; consumers may derive an unresolved ledger view
only when an engagement has no later explicit outcome. It never infers an
outcome from turn absence, scene termination, initiative order, hit-point
guesses, or other artifacts.
The [JSON output contract](json-output.md) defines publication. Configuration
keys, required generated-reference slots, and validator-chain selection are
defined in the [configuration reference](../config.md). Implementation and
prompt-grounding mechanics are described in the
[D&D module internals](../internal/dnd.md).

View File

@@ -60,10 +60,13 @@ relationship fields.
Only individually identifiable NPC names with transcript evidence belong in
this artifact. Groups, generic roles, invented labels, and descriptive
enrichment are excluded. Its source references prove registry provenance; they
do not become evidence for a spell, interaction, or combat occurrence.
do not become evidence for a spell, interaction, combat, or enemy-event
occurrence.
This registry can ground actor or caster names in the [spell](dnd-spell-artifacts.md)
and [combat-turn](dnd-combat-turn-artifacts.md) artifacts. It is required to
resolve the canonical `name` in an [NPC interaction](dnd-npc-interaction-artifacts.md).
The [enemy-event artifact](dnd-enemy-event-artifacts.md) also uses it only for
subject grounding and canonical display names.
The [JSON output contract](json-output.md) defines publication, and
[D&D module internals](../internal/dnd.md) owns pipeline mechanics.

View File

@@ -74,5 +74,8 @@ Only entries with the same canonical name, kind, and complete valid evidence
sequence are collapsed; distinct categories or evidence remain separate.
See the [combat-turn artifact](dnd-combat-turn-artifacts.md) for combat-action
occurrences and the [JSON output contract](json-output.md) for publication.
Pipeline mechanics are described in [D&D module internals](../internal/dnd.md).
occurrences. The [enemy-event artifact](dnd-enemy-event-artifacts.md) consumes
only `combat_opponent` interactions as grounding; they never establish an enemy
event or outcome. The [JSON output contract](json-output.md) defines
publication. Pipeline mechanics are described in
[D&D module internals](../internal/dnd.md).

View File

@@ -62,8 +62,9 @@ durable fields, or the same source range with different kind, title, or
summary, is invalid. It does not merge adjacent ranges, alter prose, or infer
missing scenes.
The [combat-turn artifact](dnd-combat-turn-artifacts.md) uses an exact matching
The [combat-turn artifact](dnd-combat-turn-artifacts.md) and
[enemy-event artifact](dnd-enemy-event-artifacts.md) use an exact matching
`combat` scene only as eligibility control; scene title, summary, and source
reference never become combat evidence. Publication is defined by the
reference never become their evidence. Publication is defined by the
[JSON output contract](json-output.md); implementation details live in
[D&D module internals](../internal/dnd.md).

View File

@@ -74,8 +74,9 @@ than infer a lane schema from its name. The current D&D payload contracts are
[spells](dnd-spell-artifacts.md), [NPCs](dnd-npc-artifacts.md),
[NPC interactions](dnd-npc-interaction-artifacts.md),
[combat turns](dnd-combat-turn-artifacts.md),
[item events](dnd-item-event-artifacts.md), and
[scene descriptions](dnd-scene-description-artifacts.md).
[item events](dnd-item-event-artifacts.md),
[scene descriptions](dnd-scene-description-artifacts.md), and
[enemy events](dnd-enemy-event-artifacts.md).
## `manifest.json`

View File

@@ -7,7 +7,7 @@ selectable keys, bindings, reference syntax, and default validator chains.
## Durable Artifact Contracts
The six lanes have separate durable wire contracts. This guide deliberately
The seven lanes have separate durable wire contracts. This guide deliberately
does not repeat their JSON shapes or schemas.
| Lane | Durable contract |
@@ -18,6 +18,7 @@ does not repeat their JSON shapes or schemas.
| Item events | [item-event artifacts](../integrations/dnd-item-event-artifacts.md) |
| NPC interactions | [NPC-interaction artifacts](../integrations/dnd-npc-interaction-artifacts.md) |
| Scene descriptions | [scene-description artifacts](../integrations/dnd-scene-description-artifacts.md) |
| Enemy events | [enemy-event artifacts](../integrations/dnd-enemy-event-artifacts.md) |
## Family Composition
@@ -101,14 +102,19 @@ relatedness validators report advisory evidence concerns. The configured order
is documented in
[Configuration](../config.md#production-validator-keys-and-default-chains).
Enemy-event extraction additionally rejects a second `engaged` observation for
the same comparison identity within one scene-scoped result. Normalization may
combine results from distinct scenes, so it intentionally does not apply that
rule. Configuration owns the exact validator key and chain position.
Normalizers are deterministic for spells, combat turns, item events, NPC
interactions, and scene descriptions. They canonicalize display values and
evidence, use source-document order for stable output, and issue bounded
warnings for changes or collapsed duplicates. The NPC normalizer is the
intentional exception: it first produces a deterministic candidate set, then
uses a bounded structured-LLM proposal to reconcile identity groups. Invalid
or unusable proposals retain the deterministic result and surface retry or
fallback diagnostics; the model does not directly replace durable records.
interactions, scene descriptions, and enemy events. They canonicalize display
values and evidence, use source-document order for stable output, and issue
bounded warnings for changes or collapsed duplicates. The NPC normalizer is
the intentional exception: it first produces a deterministic candidate set,
then uses a bounded structured-LLM proposal to reconcile identity groups.
Invalid or unusable proposals retain the deterministic result and surface retry
or fallback diagnostics; the model does not directly replace durable records.
## Generated References And Grounding
@@ -122,7 +128,10 @@ NPC registries are names-only grounding projections: they may canonicalize
actors for spells and combat turns and are required for NPC interactions, but
they do not supply evidence. Scene-description registries are eligibility-only
projections: they retain the current chunks classification data, not scene
prose or evidence, and exist to route combat extraction.
prose or evidence, and exist to route combat extraction. Enemy-event extraction
also projects combat turns to `actor` and `turn_kind` and filters NPC
interactions to `combat_opponent` names and kinds. These compact projections,
like NPC grounding, are source-free guidance and never event evidence.
## Lane-Specific Rules
@@ -137,11 +146,15 @@ shared helper changes.
| Item events | Uses campaign context for disambiguation but has no NPC-registry or scene-description dependency. |
| NPC interactions | Requires the normalized NPC registry at extraction and normalization, using it for canonical actor grounding only. |
| Scene descriptions | Produces the classifications consumed by combat routing; it does not consume an NPC registry or provide evidence for combat artifacts. |
| Enemy events | Requires NPC, scene-description, combat-turn, and NPC-interaction artifacts. It calls the LLM only for an exact `combat` classification, records ordered observations rather than terminal state, and normalizes recognized names through the NPC registry while preserving grounded collective labels. |
The combat and scene-description contracts describe their exact handoff and
empty-result behavior in more detail:
[combat turns](../integrations/dnd-combat-turn-artifacts.md) and
[scene descriptions](../integrations/dnd-scene-description-artifacts.md).
The [enemy-event contract](../integrations/dnd-enemy-event-artifacts.md)
defines its durable semantics; [Configuration](../config.md) owns its
selectable bindings and validation chains.
## Focused Verification

View File

@@ -7,24 +7,6 @@ not as committed release dates.
## Near-Term D&D Pipeline
### Combat Enemy Ledger
- Add a D&D artifact that identifies enemies faced during combat and supports
an end-of-session encounter ledger.
- Track each enemy's observed state using a small controlled vocabulary such as
`active`, `killed`, `fled`, `captured`, or `incapacitated`, while preserving
an explicit unresolved state when the transcript does not establish an
outcome.
- Preserve the evidence for enemy participation and state changes rather than
inferring a terminal outcome from combat ending or an enemy disappearing
from the conversation.
- Define how repeated mentions, groups of unnamed enemies, summoned or allied
creatures, and the same enemy appearing in multiple combats affect identity
and ledger entries.
- Evaluate whether the ledger should be extracted directly, derived from
combat-turn artifacts, or use a sequential pipeline that consumes combat
turns and the normalized NPC registry as grounding references.
### Location Extraction
- Add a D&D artifact for locations visited by the party or otherwise mentioned

View File

@@ -0,0 +1,649 @@
# D&D Location Tracking Implementation Plan
## Objective
Implement the target state in [D&D Location Tracking](location.md): an
evidence-grounded `dnd/locations` registry lane and a dependent
`dnd/location-occurrences` lane, including conservative location identity,
shared D&D entity-reconciliation infrastructure, production validation,
generated-reference wiring, maintained examples, and current documentation.
This is an ordered implementation plan for a `gpt-5.6-terra` coding agent.
Implement one stage per prompt, in order. Finish each stage's tests and leave
the repository coherent before proceeding. Do not implement later-stage
production registrations early merely to make an incomplete feature selectable.
All stages must follow:
- [Architecture Policy](../policy/architecture.md)
- [Testing Policy](../policy/testing.md)
- [Documentation Policy](../policy/documentation.md)
- the D&D conventions in [D&D Module Internals](../internal/dnd.md)
- the durable policy decisions in [the feature roadmap](location.md)
Use behavior-level tests. Do not add tests that merely freeze source layout,
exact prompt wording, message counts, shared-prefix length, or other incidental
implementation details. Keep tests deterministic, offline, and owned by the
component whose behavior they exercise.
## Stage 1: Define Location Domain Types And Identity
### Goal
Establish the in-process contracts and deterministic identity policy on which
both lanes depend.
### Work
- Extend `internal/modules/dnd/types.go` with:
- `LocationListKind` = `dnd/location-list`;
- `LocationOccurrenceListKind` = `dnd/location-occurrence-list`;
- `LocationList`, `Location`, `LocationOccurrenceList`,
`LocationOccurrence`, and `LocationOccurrenceKind`;
- exact JSON members and the four occurrence constants specified in
`location.md`.
- Add `internal/modules/dnd/locations/identity`.
- Implement display normalization and comparison normalization consistently
with the existing NPC identity policy. Share a lower-level comparison helper
only if doing so preserves NPC behavior exactly; otherwise keep the small
policy-specific function explicit.
- Implement the versioned compact-JSON-array ID derivation contract from
`location.md`, including ID syntax checks and immutable list validation.
- Identity validation must require correctly derived, unique IDs while allowing
two records to have the same comparison name when their evidence anchors
differ.
- Add focused tests for Unicode normalization, whitespace, apostrophes,
deterministic encoding, evidence ordering, same-name/different-anchor IDs,
malformed IDs, missing evidence, and non-mutation.
### Acceptance Criteria
- The types compile without production registration.
- ID derivation exactly follows the documented five-element compact JSON input.
- Same normalized name plus different earliest evidence yields different IDs.
- Validation does not reject same-name records solely because their names
match, and it reports duplicate or mismatched IDs deterministically.
- `go test ./internal/modules/dnd/locations/... ./internal/modules/dnd/...` passes
for the packages available at this stage.
### Prompt Size
Small enough for one implementation prompt.
## Stage 2: Add Durable Codecs And Schemas
### Goal
Create strict durable JSON ownership for both artifact kinds without exposing
either lane as a selectable pipeline yet.
### Work
- Add `internal/modules/dnd/codec/locations` and
`internal/modules/dnd/codec/locationoccurrences`, following the existing D&D
candidate/approved codec pattern.
- Add embedded Draft 2020-12 schemas with the IDs, names, root members, required
fields, enums, source-reference shape, and `additionalProperties: false`
contracts in `location.md`.
- Keep both schemas at `v1`.
- Support strict candidate decoding before semantic approval and strict durable
encoding/decoding after approval.
- Add representative valid fixtures and tests for schema metadata, defensive
schema bytes, empty arrays, unknown fields, missing fields, invalid types,
invalid enum values, malformed source references, invalid ID syntax, and
round trips.
### Acceptance Criteria
- Each codec advertises the correct artifact kind and metadata count.
- Candidate decoding preserves semantic mistakes for validators while
rejecting structurally invalid JSON.
- Approved encoding and decoding enforce the durable shape.
- `go test ./internal/modules/dnd/codec/locations/... ./internal/modules/dnd/codec/locationoccurrences/...`
passes offline.
### Prompt Size
Small enough for one implementation prompt.
## Stage 3: Extract Shared Entity-Reconciliation Infrastructure
### Goal
Create the D&D-shared, domain-safe proposal machinery needed by both NPC and
location normalization, without changing NPC production behavior yet.
### Work
- Add `internal/modules/dnd/shared/entityreconcile`.
- Move or generalize the reusable behavior currently owned by
`internal/modules/dnd/normalize/npcs/context_material.go` and `proposal.go`:
- assign deterministic opaque candidate keys such as `candidate-000001` in
input order;
- clone candidate names and source references;
- build bounded transcript windows in source-document order;
- omit candidates whose references cannot safely produce context;
- coalesce overlapping or adjacent windows without mutating the source;
- define the private `duplicate_groups` proposal with `members` and
`canonical` candidate keys;
- reject blank, unknown, repeated, ineligible, overlapping, too-small, or
canonical-not-a-member groups; and
- return defensive, immutable assessment data identifying only safe groups.
- Keep LLM calls, retry decisions, artifact mutation, canonical-name policy,
durable ID derivation, and warning wording out of this package.
- Add a shared prompt instruction asset that states the key-copying and
proposal-safety contract without NPC- or location-specific identity rules.
- Add a shared private structured-response schema and loader/registration
support with a stable `v1` key, ID, name, and fingerprint. Registering the
schema more than once must not be required.
- Add table-driven tests for context bounds, ordering, invalid references,
coalescing, every unsafe proposal category, non-overlapping safe groups,
deterministic keys, defensive copies, and non-mutation.
### Acceptance Criteria
- The package has no dependency on `dnd.NPC`, `dnd.Location`, either
normalizer, or a concrete LLM client.
- Proposal values can identify duplicate candidates even when display names
are equal.
- The shared response contract cannot directly supply replacement records or
evidence.
- Existing NPC packages still compile before their migration.
- `go test ./internal/modules/dnd/shared/...` passes offline.
### Prompt Size
Medium, but coherent and suitable for one implementation prompt. Do not combine
it with the NPC migration.
## Stage 4: Migrate NPC Normalization To The Shared Helper
### Goal
Make the existing NPC normalizer the first production consumer of the shared
entity-reconciliation contract while retaining its durable behavior.
### Work
- Refactor `internal/modules/dnd/normalize/npcs` to use opaque candidate keys,
shared context construction, shared proposal assessment, the shared response
schema, and the shared generic reconciliation instruction asset.
- Retain NPC-owned responsibilities:
- comparison-name preparation and deterministic duplicate handling;
- the NPC-specific task and canonical display-name rules;
- LLM invocation, bounded retry, fallback, warnings, and diagnostics;
- application of safe groups, evidence union, NPC ID derivation, and output
ordering.
- Remove superseded NPC-private context/proposal code and private schema assets
once no longer referenced.
- Update NPC prompt metadata and checkpoint fingerprints for the intentional
prompt/private-schema contract change.
- Preserve public module keys, durable NPC schema, identity policy, validator
chains, warning bounds, and fallback semantics.
- Test equal display names as distinct keyed candidates, alias consolidation,
rejected unsafe groups, retry exhaustion, private input ownership, redacted
errors, deterministic fallback, and non-mutation.
### Acceptance Criteria
- No durable NPC artifact field or module key changes.
- NPC normalization cannot confuse two candidates merely because their display
names match.
- Unsafe proposals leave a valid deterministic result and follow existing
retry/fallback policy.
- Obsolete NPC-only reconciliation helpers and schema are removed.
- `go test ./internal/modules/dnd/normalize/npcs/... ./internal/modules/dnd/shared/...`
passes offline.
### Prompt Size
Medium-to-large but bounded to one existing module. Suitable for one
implementation prompt; do not add location normalization in this stage.
## Stage 5: Implement Location Extraction
### Goal
Add the LLM-backed extractor that produces evidence-grounded location
candidates.
### Work
- Add `internal/modules/dnd/extract/locations` following current D&D extractor
conventions: strict empty options, typed builder and registration function,
`llm_backed` execution metadata, immutable inputs, redacted errors, prompt
and response-schema fingerprints, and bounded diagnostics.
- Add a private response schema containing only `name` and source ranges; the
model must not produce durable IDs or prose.
- Compose the prompt from existing shared D&D system, identity, campaign
reference, transcript, and evidence assets plus module-owned task and
instructions. Preserve the documented extraction-message ordering and cache
controls.
- Define physical-place inclusion and conservative omission exactly as in
`location.md`, including generic labels, aliases, and nested places.
- Map source ranges to the current source ID, canonicalize exact duplicate
ranges, derive candidate location IDs in code, preserve semantically invalid
candidates for validators where safe, and return deterministic ordering.
- Add prompt-asset tests that verify shared asset reuse and rendered inputs by
behavior, without asserting exact shared-prefix length or prompt wording.
- Add extractor tests for empty output, mapping, evidence ownership, generic
same-name locations with different anchors, invalid candidate preservation,
client failures, registration metadata, fingerprints, and non-mutation.
### Acceptance Criteria
- The extractor cannot manufacture source identities or accept campaign
references as evidence.
- The private model response does not contain a durable ID.
- Same-name candidates with different evidence survive extraction as distinct
candidates.
- The package is testable through its local registration but is not yet added
to the production D&D registrar.
- `go test ./internal/modules/dnd/extract/locations/...` passes offline.
### Prompt Size
Medium and suitable for one implementation prompt.
## Stage 6: Implement Semantic Location Normalization
### Goal
Add conservative alias and repeated-place reconciliation without collapsing
same-named or nested locations by default.
### Work
- Add `internal/modules/dnd/normalize/locations` as an `llm_backed` normalizer
using the shared entity-reconciliation package and private response schema.
- Deterministically clone and prepare the merged candidates first:
- normalize display whitespace;
- canonicalize and deduplicate source references;
- remove only exact duplicates with the same comparison name and exact
canonical evidence;
- assign opaque reconciliation keys; and
- retain same-name records with different evidence.
- Use bounded transcript windows and a module-owned location task that permits
grouping only when evidence clearly identifies one physical place. Explicitly
prohibit grouping solely by equal names, proximity, nesting, or generic
labels.
- Validate proposals through the shared package. Apply only safe groups in
deterministic code, choose the canonical name from the selected existing
candidate, union evidence, and derive the final evidence-anchored ID.
- Retain the deterministic candidate set on unusable proposals and follow the
existing NPC retry/fallback and bounded-warning conventions.
- Publish prompt, response-schema, identity-policy, normalization-policy, and
semantic-context fingerprints.
- Test aliases, repeated appearances, same-name distinct places, parent/child
locations, invalid and overlapping proposals, proposal retries, fallback,
ordering, ID recomputation, warning bounds, idempotent deterministic
application, and non-mutation.
### Acceptance Criteria
- The model proposes groups but cannot directly replace durable locations.
- A failed or ambiguous proposal cannot lose a valid candidate.
- Same-name locations remain distinct unless an approved evidence-backed group
joins them.
- Final IDs are derived only after group evidence is unioned.
- `go test ./internal/modules/dnd/normalize/locations/...` passes offline.
### Prompt Size
Medium-to-large but scoped to one normalizer and suitable for one implementation
prompt.
## Stage 7: Add The Immutable Location Registry
### Goal
Provide safe generated-reference resolution and an unambiguous prompt
projection for downstream occurrence extraction.
### Work
- Add `internal/modules/dnd/locations/registry`, modeled on the immutable NPC
registry and its operation-time resolver.
- Define `ReferenceSlot = "locations"`, a 1,048,576-byte limit, and exactly one
accepted `application/json` location-list item when bound.
- Validate durable decoding and location identity before constructing a
registry.
- Store canonical durable bytes and semantic digests without retaining mutable
caller-owned content. Return defensive copies from all accessors.
- Produce a compact, source-free prompt projection containing ordered
`{id, name}` pairs. Do not include source references or generated-reference
provenance.
- Support exact lookup by ID and verify the matching canonical name; do not
provide an ambiguous name-only lookup as the occurrence linkage mechanism.
- Preserve the established seeded/operation resolver behavior and concurrency-
safe semantic caching.
- Test absent, empty, malformed, oversized, wrong-media-type, invalid-identity,
and valid registries; projections; ID lookup; defensive copies; raw and
semantic cache reuse; and concurrent resolution.
### Acceptance Criteria
- Distinct same-name records are both representable and addressable by ID.
- Registry evidence cannot appear in the prompt projection.
- Malformed static references fail during construction and malformed generated
references fail at operation resolution through existing boundaries.
- `go test ./internal/modules/dnd/locations/registry/...` passes offline.
### Prompt Size
Medium and suitable for one implementation prompt.
## Stage 8: Implement Location-Occurrence Extraction
### Goal
Add the dependent LLM-backed lane that classifies source-grounded location
occurrences.
### Work
- Add `internal/modules/dnd/extract/locationoccurrences` with module key
`dnd/location-occurrences`, `llm_backed` execution metadata, strict empty
options, typed construction, and a required `locations` reference slot.
- Resolve the immutable registry at construction and for each operation using
the established generated-reference pattern.
- Add a private response schema requiring `location_id`, `name`, `kind`, and
source ranges. Restrict kinds to `visited`, `planned`, `recalled`, and
`mentioned`.
- Reuse the shared D&D extraction prompt assets and ordering. Place the compact
location registry after the shared transcript/evidence material and before
module task/instructions, consistent with current generated grounding.
- Encode the exact classification rules, precedence, multi-fact behavior, and
conservative omission policy from `location.md`.
- Map evidence only to the current source. Copy candidate IDs and names without
silently repairing unknown or mismatched values so deterministic validators
retain ownership of those diagnostics.
- Canonically order output and exact duplicates without dropping distinct
kinds or independent evidence.
- Test every kind, precedence, multiple supported facts, no-location and no-
occurrence outputs, required registry failures, same-name ID selection,
source-free prompt projection, current-transcript evidence, prompt/profile
metadata, client failures, non-mutation, and local registration.
### Acceptance Criteria
- Construction and operation specs declare `locations` as required and accept
only `dnd/location-list` JSON.
- The model sees IDs and names but no registry evidence.
- Registry context never becomes occurrence evidence.
- The extractor remains locally testable but is not production-selectable yet.
- `go test ./internal/modules/dnd/extract/locationoccurrences/...` passes
offline.
### Prompt Size
Medium and suitable for one implementation prompt.
## Stage 9: Implement Deterministic Occurrence Normalization
### Goal
Canonicalize occurrence records against the exact location registry without a
second LLM call.
### Work
- Add `internal/modules/dnd/normalize/locationoccurrences` as a deterministic
normalizer with the same required `locations` slot.
- Clone all inputs. Normalize source ranges, names, ordering, and exact
duplicates.
- For a known `location_id`, replace display-name variation with the registry's
exact canonical name. Do not perform a name-only guess.
- Preserve an unknown ID or otherwise invalid record for validator diagnostics
and emit bounded warnings where current D&D normalizer conventions require
them.
- Sort using the complete order defined in `location.md`.
- Publish normalization and registry-projection fingerprints consistent with
the other registry-backed normalizers.
- Test all kind values, canonical name replacement, same-name distinct IDs,
exact-duplicate removal, distinct evidence retention, stable ordering,
unknown IDs, malformed registry resolution, warnings, idempotence, and
non-mutation.
### Acceptance Criteria
- No LLM client or prompt assets are required.
- Canonicalization is exclusively ID-based.
- Invalid records are not silently redirected to a different location.
- `go test ./internal/modules/dnd/normalize/locationoccurrences/...` passes
offline.
### Prompt Size
Small enough for one implementation prompt.
## Stage 10: Add Location Registry Validators
### Goal
Give `dnd/location-list` the complete validator ownership expected of a
production D&D artifact.
### Work
- Add location validator packages under
`internal/modules/dnd/validate/locations` for:
- extraction shape;
- source-reference bounds/current-chunk ownership;
- advisory source relatedness; and
- normalized identity derivation and ID uniqueness.
- Use shared D&D citation, unit-reference, diagnostic, and matching helpers
where their contracts apply.
- The identity validator must allow repeated comparison names and validate the
evidence-anchored derivation policy instead of importing NPC uniqueness
assumptions.
- Keep diagnostics indexed, aggregated, bounded, stable, and free of raw prompt
or reference content.
- Add tests for accepted values, every owned failure, same-name distinct
locations, malformed/unreadable citations, advisory relatedness, bounds,
registration metadata, fingerprints, nil safety where applicable, and
non-mutation.
### Acceptance Criteria
- Validator responsibilities do not overlap merely to increase test coverage.
- Relatedness remains advisory and uses only cited current-transcript text.
- Validators do not repair or mutate artifacts.
- `go test ./internal/modules/dnd/validate/locations/...` passes offline.
### Prompt Size
Medium and suitable for one implementation prompt.
## Stage 11: Add Location-Occurrence Validators
### Goal
Give `dnd/location-occurrence-list` complete structural, registry, ordering,
evidence, and advisory validation.
### Work
- Add validator packages under
`internal/modules/dnd/validate/locationoccurrences` for:
- extraction shape and supported kinds;
- required registry membership and exact `location_id`/`name` pairing;
- normalized ordering and exact-duplicate invariants;
- source-reference bounds/current-chunk ownership; and
- advisory source relatedness.
- Reuse the immutable location resolver rather than decoding caller-owned
references independently in each validator.
- Ensure same-name registry records remain distinguishable by ID.
- Keep registry context out of evidence checks.
- Add focused tests for each kind, unknown IDs, mismatched names, same-name
locations, ordering, duplicates, malformed required references, invalid
evidence, advisory diagnostics, registration metadata, fingerprints,
diagnostic bounds, and non-mutation.
### Acceptance Criteria
- An ID/name mismatch is rejected even when another registry record has the
supplied name.
- Missing or malformed required registry references fail at the established
boundary.
- Validators remain deterministic and do not alter occurrence records or the
registry.
- `go test ./internal/modules/dnd/validate/locationoccurrences/...` passes
offline.
### Prompt Size
Medium and suitable for one implementation prompt.
## Stage 12: Compose The Production D&D Family
### Goal
Make both lanes selectable as one coherent production addition after all
component contracts are present.
### Work
- Extend `internal/modules/dnd/register` to register, in dependency-safe order:
- both codecs;
- both extractors;
- typed append-order mergers;
- the LLM-backed location normalizer;
- the deterministic occurrence normalizer;
- all validators;
- shared reconciliation schema assets and both new prompt manifests;
- evidence projectors; and
- extract and normalize default validator chains.
- Ensure both LLM-backed modules select the maintained `dnd-extraction`
fallback profile and inherit the existing profile policy.
- Define chain order consistently with existing D&D artifacts: generic JSON,
shape, registry/identity or normalized invariants at the appropriate stage,
source references, durable JSON Schema, then advisory relatedness.
- Update registrar tests for artifact kinds, keys, execution classes, reference
slot requirements, builder construction, assets, profile use, evidence
projection, chain contents/order, duplicate registration, and failure
propagation.
- Update any integration-level artifact-kind allowlists or typed registries
required by the framework; do not add module-specific orchestration logic.
### Acceptance Criteria
- One D&D registration call exposes both complete lanes and no partial
registration succeeds silently.
- Catalog inspection reports correct artifact kinds, stages, execution classes,
reference slots, profiles, and fingerprints.
- Both artifact kinds support evidence projection without registry evidence
leakage.
- `go test ./internal/modules/dnd/register/... ./internal/modules/dnd/...` passes
offline.
### Prompt Size
Medium-to-large but limited to composition and suitable for one implementation
prompt.
## Stage 13: Add Maintained Pipeline And Handoff Coverage
### Goal
Exercise the feature through real configuration, ordered generated references,
chunk operations, acceptance gates, and durable output.
### Work
- Update `examples/dnd-complete.config.yml`:
- add `locations` to the first descriptive step with extract, append-order
merge, and LLM-backed normalize bindings;
- add a generated `locations` reference to the next step;
- add `location-occurrences` to that step with extract, append-order merge,
and deterministic normalize bindings; and
- add both lanes to evidence-context output where appropriate.
- Keep the minimal example minimal unless its stated purpose requires a
location lane; do not turn it into a second complete example.
- Extend maintained example-loading/config-validation tests.
- Add integration tests that prove:
- the normalized accepted registry is handed off in memory;
- the occurrence lane cannot run before its producer;
- missing, cyclic, wrong-kind, wrong-media-type, rejected, or unaccepted
producers are rejected at the existing boundaries;
- same-name locations remain distinguishable by ID through the handoff;
- registry evidence never becomes occurrence evidence;
- retries and checkpoints honor prompt, schema, identity, and generated-
reference fingerprints; and
- output contains both durable artifact envelopes and evidence context.
- Use recording/fake structured clients only; no network-dependent tests.
### Acceptance Criteria
- The complete example loads through the real config path and exercises all
registered D&D lanes.
- Ordered handoff failure semantics match the framework's existing fail-whole-
run policy.
- Integration tests cover behavior rather than duplicating package internals.
- `go test ./internal/modules/integration/... ./internal/config/...` and any
example-specific test targets pass offline.
### Prompt Size
Medium-to-large but coherent as one end-to-end integration prompt.
## Stage 14: Publish Current Documentation And Perform Final Verification
### Goal
Make the implemented feature discoverable and retire fulfilled future-work
language without leaving development-history documentation behind.
### Work
- Create canonical integration contracts:
- `docs/integrations/dnd-location-artifacts.md`;
- `docs/integrations/dnd-location-occurrence-artifacts.md`.
- Update `docs/config.md` with both selectable keys, the `locations` reference
slot and compatibility, execution classes, validators, default chains, and
the complete-example link.
- Update `docs/internal/dnd.md` with nine-lane composition, shared entity
reconciliation, evidence-anchored identity, occurrence grounding, prompt
asset reuse, and intentional lane differences. Link to integration contracts
instead of duplicating their JSON shapes.
- Update `docs/integrations/json-output.md`, `README.md`, and other current
canonical inventories only where repository inspection shows that the new
artifact kinds or maintained example must be listed.
- Remove the fulfilled `Location Extraction` section from
`docs/roadmap/future.md`. Keep the generic LLM-assisted deduplication item and
clarify only if needed that the new D&D helper does not fulfill that broader
feature.
- Verify all relative links and search current documentation for stale
seven-lane counts, missing keys, obsolete location-planning claims, and
accidental claims that references are evidence.
- Run formatting, focused tests, the full Go test suite, static analysis, and
repository-provided config/example checks. Inspect `git diff --check` and
confirm no unrelated files changed.
- After implementation and verification are complete, leave `location.md` and
this plan in place for the user's separate roadmap-retirement step; do not
delete them unless explicitly asked.
### Acceptance Criteria
- Current documentation describes the implemented contracts and only
implemented behavior outside `docs/roadmap/`.
- The roadmap no longer presents completed location tracking as future work.
- Links, examples, module inventories, and default-chain tables agree with
production registration.
- `go test ./internal/modules/dnd/...` passes.
- `go test ./internal/modules/integration/...` passes.
- `go test ./...` passes.
- `go vet ./...` passes.
- Repository-provided configuration/example validation passes.
- `git diff --check` reports no errors.
### Prompt Size
Medium and suitable for one implementation prompt.
## Open Questions
None. The feature roadmap fixes the artifact shapes, identity scope and
derivation, occurrence categories, classification precedence, reference
dependency, reconciliation safety boundary, pipeline placement, and non-goals
needed to implement every stage without an additional product decision.

337
docs/roadmap/location.md Normal file
View File

@@ -0,0 +1,337 @@
# D&D Location Tracking
## Purpose
Add evidence-grounded D&D location tracking without turning a single extractor
into both an entity registry and an event classifier. The target design follows
the established NPC pattern: one lane identifies canonical location records and
a later lane records how the party related to those locations in the transcript.
This roadmap defines the desired end state and policy choices. The ordered work
needed to reach that state is in [the implementation plan](implementation.md).
## User Intent
- Record locations the party visits or that the session otherwise discusses.
- Distinguish current physical presence from plans, recollections, and ordinary
mentions.
- Preserve transcript evidence for every durable record.
- Reconcile aliases and repeated appearances conservatively.
- Keep distinct places separate when they happen to share a generic name.
- Keep the schemas minimal. Location description, hierarchy, participants, and
narrative analysis belong in other artifacts or deterministic joins.
## Target Capability
The D&D module family will have two new lanes:
1. `dnd/locations` produces a session-scoped registry of physical places.
2. `dnd/location-occurrences` consumes the normalized location registry and
produces an ordered list of source-grounded relationships between the party
and those places.
The normalized location artifact is handed to the occurrence lane through a
required generated reference named `locations`. The occurrence lane must use
that registry for identity grounding, but the current transcript remains its
only evidence source.
## Durable Artifact Contracts
Both contracts remain at `v1`; Notarius is pre-release and does not need a
compatibility layer for these new artifacts.
### Location registry
The location lane uses:
- artifact kind: `dnd/location-list`
- module key: `dnd/locations`
- schema ID: `notarius.dnd.locations`
- schema name: `notarius_dnd_locations_v1`
- media type: `application/json`
- root member: `locations`
Each location contains exactly:
| Field | Type | Meaning |
| --- | --- | --- |
| `id` | string | Deterministic, session-scoped canonical location identity. |
| `name` | string | Evidence-grounded display name or transcript-established label. |
| `source_refs` | non-empty source-reference array | Current-transcript evidence that identifies the place. |
Locations are physical or spatial places: planes, regions, settlements,
districts, buildings, rooms, landmarks, routes, and geographic features. A
generic label such as `the tavern` is permitted only when the transcript uses
it for a specific place. The extractor must not invent a qualifier merely to
distinguish that place from another place with the same label.
The registry does not contain type, parent, description, summary, coordinates,
participants, visit status, or occurrence data. Parent and child places are
separate identities when the transcript identifies both; nesting alone is not
a reason to merge them.
### Location occurrences
The occurrence lane uses:
- artifact kind: `dnd/location-occurrence-list`
- module key: `dnd/location-occurrences`
- schema ID: `notarius.dnd.location_occurrences`
- schema name: `notarius_dnd_location_occurrences_v1`
- media type: `application/json`
- root member: `occurrences`
Each occurrence contains exactly:
| Field | Type | Meaning |
| --- | --- | --- |
| `location_id` | string | An exact ID from the consumed normalized location registry. |
| `name` | string | The canonical display name associated with `location_id`. |
| `kind` | enum | `visited`, `planned`, `recalled`, or `mentioned`. |
| `source_refs` | non-empty source-reference array | Current-transcript evidence for both the place and the classified occurrence. |
`location_id` is required even though existing NPC interactions currently use
name-only grounding. Locations can legitimately share the same display name,
so a name alone cannot provide an unambiguous cross-artifact link. The name is
retained for readable standalone output and must exactly match the registry
record selected by the ID after normalization.
## Identity Policy
Location identity is conservative and scoped to one source document. It is not
a campaign-wide or cross-session world identity.
Display normalization trims surrounding whitespace and collapses internal
Unicode whitespace. Comparison normalization uses the existing D&D entity
rules: Unicode NFKC normalization, normalized apostrophes, collapsed
whitespace, and Unicode case folding.
The canonical ID is:
~~~text
location:sha256:<lowercase SHA-256 hex digest>
~~~
The digest input is the UTF-8 encoding of compact JSON for this five-element
array:
~~~text
["dnd.locations.identity.v1", comparison_name, source_id, start_unit_id, end_unit_id]
~~~
The source values come from the earliest reference after canonical reference
sorting and exact deduplication. Compact JSON array encoding is part of the
identity contract: it avoids delimiter ambiguity and must not be replaced
without changing the policy version. A blank comparison name or missing valid
source reference produces no manufactured ID and remains a validation error.
Including the evidence anchor prevents two unrelated places called `the
tavern` from receiving the same ID. When semantic normalization safely groups
aliases or repeated appearances, it first chooses an existing canonical display
name and unions the evidence; it then derives the final ID from that name and
the earliest unioned reference.
The normalizer may merge records only when transcript evidence clearly shows
that they denote the same physical place. It must not merge records solely
because:
- their comparison names are equal;
- they are near one another in the transcript;
- one is spatially nested inside the other; or
- their labels are both generic.
Distinct normalized records may therefore have the same comparison name, but
their IDs must be unique and correctly derived. Exact duplicates with the same
comparison name and canonical evidence may be collapsed deterministically.
## Occurrence Semantics
Each occurrence has one kind:
- `visited`: current-session gameplay establishes that one or more party
members are physically present at the location, including an arrival,
continuing presence, or departure.
- `planned`: the party explicitly proposes, intends, or agrees to future travel
to the location. Mere hypotheticals or speculation are not plans.
- `recalled`: the transcript explicitly recounts or recaps the party being at
the location before the current session's live events.
- `mentioned`: the location is explicitly referenced but the occurrence does
not meet a stronger definition. This includes lore, directions, third-party
activity, non-actionable speculation, and out-of-character discussion.
An inferred but unstated place produces no location or occurrence. Uncertainty
is handled by conservative omission rather than an `uncertain` enum value.
For one occurrence supported by overlapping evidence, classification precedence
is `visited`, then `planned`, then `recalled`, then `mentioned`; `mentioned` is
the fallback. A passage may produce multiple records when it independently
supports separate facts, such as recalling an earlier visit while planning a
return. Exact duplicates with the same ID, kind, and canonical evidence are
collapsed. Different kinds or independently supported evidence remain.
Output is ordered by earliest evidence in source-document order, then by
`location_id`, `name`, kind order (`visited`, `planned`, `recalled`,
`mentioned`), and the remaining canonical reference sequence.
## Extraction, Normalization, And Evidence
### Location registry lane
The extractor is LLM-backed and follows the shared D&D extraction prompt and
input conventions. It emits names and source ranges through a private response
schema; deterministic mapping supplies the current source ID and derives
candidate IDs. Campaign references may disambiguate terminology but never
become durable evidence.
The merger uses the typed append-order convention. The normalizer is LLM-backed:
it deterministically prepares names and evidence, then asks the model only for
duplicate groups. The model may identify groups and choose a canonical member,
but it may not create, delete, rewrite, or directly replace durable records.
Code validates the proposal, applies non-overlapping safe groups, unions
evidence, derives final IDs, orders output, and emits bounded warnings.
Malformed, unknown, overlapping, or ambiguous proposal groups are rejected.
The normalizer uses the existing bounded retry behavior and falls back to the
safe deterministic candidate set if no usable proposal is obtained.
### Location occurrence lane
The extractor is LLM-backed and requires exactly one validated `locations`
reference. The prompt projection contains only ordered `{id, name}` pairs; it
omits registry evidence and reference provenance. The model must copy both
values from one projected record and cite current-transcript source ranges for
the occurrence.
The occurrence normalizer is deterministic. It canonicalizes names by exact
registry ID, normalizes evidence and ordering, and removes exact duplicates.
Unknown IDs and mismatched ID/name pairs remain inspectable validation failures
rather than being guessed or silently reassigned.
The occurrence lane cannot add a missing location to the registry. If the
location extractor omitted a place, the correct behavior is to omit its
occurrence and improve the upstream extraction later.
## Shared Entity Reconciliation
Adding a second LLM-assisted entity registry demonstrates a concrete shared
need in the D&D domain. The existing NPC normalization context-window and
proposal-safety logic will move to
`internal/modules/dnd/shared/entityreconcile` and serve both NPC and location
normalizers.
The shared package owns:
- deterministic opaque candidate keys;
- source-window construction and canonical prompt materials;
- a common private duplicate-group response contract;
- validation of unknown, repeated, overlapping, malformed, or ineligible
candidate keys; and
- immutable assessment results identifying safe groups.
It does not call the LLM, choose domain-specific canonical names, derive
durable IDs, mutate domain artifacts, or format domain warnings. Those
responsibilities remain in each normalizer.
NPC normalization will migrate to the shared key-based proposal contract
without changing its durable NPC behavior. Its prompt and private response
schema fingerprints are expected to change, so stale NPC normalization
checkpoints will invalidate normally.
The two normalizers will reuse an exactly identical shared reconciliation
instruction asset and private response schema. Module-owned task text will
continue to define the different NPC and location identity rules. This keeps
shared prompt content identical without pretending the two domains have the
same semantic merge policy.
This helper is intentionally D&D-specific. It does not implement the broader
domain-neutral replacement-element normalizer still described in
[future work](future.md).
## Reference Contract And Pipeline Placement
The generated reference slot is named `locations` and accepts exactly one JSON
artifact of kind `dnd/location-list`, with the established 1 MiB limit. It is
required by both extraction and normalization for
`dnd/location-occurrences`. Static file bindings remain valid where the
framework permits them, but the maintained complete example uses a generated
same-run artifact.
The complete D&D pipeline places `locations` in the first descriptive step
alongside the independent NPC, item-event, and scene-description lanes. It
places `location-occurrences` in the next step and binds the accepted normalized
location artifact from the first step. The occurrence lane has no mandatory
NPC or scene-description dependency.
No current downstream lane is changed to consume location artifacts. Future
narrative reports or joins may use the canonical IDs after defining their own
contracts.
## Validation And Production Defaults
The location registry receives production validators for:
- required shape and supported ID syntax;
- current-document and current-chunk source ranges;
- normalized identity derivation and ID uniqueness; and
- advisory source relatedness.
The location occurrence artifact receives production validators for:
- required shape and the four supported kinds;
- registry membership and exact ID/name correspondence;
- normalized ordering and exact-duplicate invariants;
- current-document and current-chunk source ranges; and
- advisory source relatedness.
Validators remain immutable and diagnostic. Durable JSON Schema validation
stays in the production chains after semantic shape and source-reference
checks, consistent with the existing D&D lanes.
## Documentation End State
Implementation will add canonical integration documents for both durable
artifacts and update current-state documentation to cover:
- both module and artifact keys;
- the `locations` generated-reference slot;
- production validators and default chains;
- D&D family composition, reconciliation, identity, and grounding behavior;
- the complete maintained pipeline example; and
- JSON output and evidence-context support.
After the feature is implemented, the fulfilled Location Extraction section is
removed from `future.md`. Historical implementation narration remains in
version control rather than current documentation.
## Out Of Scope
- Campaign-wide or cross-session canonical location IDs.
- A location ontology, hierarchy, map, coordinates, or containment graph.
- Location descriptions, summaries, participants, ownership, or encounter
analysis.
- Inferring a location that the transcript does not identify.
- Automatically creating registry records from occurrence output.
- Changing NPC-interaction artifacts to use NPC IDs.
- Making other lanes consume location references.
- A generic domain-neutral LLM deduplication framework.
- Long-term artifact-version migration machinery.
## Acceptance Criteria
- Both durable contracts are minimal, strict, versioned, and registered.
- Location IDs are deterministic under the documented policy and do not force
same-named places to collapse.
- Alias and repeat reconciliation is proposal-only, conservatively validated,
and safe on retry exhaustion.
- Occurrences use one of the four defined kinds and carry an unambiguous
registry ID/name pair plus current-transcript evidence.
- Missing, malformed, oversized, or incompatible `locations` references fail
through the established configuration or operation boundaries.
- Both lanes have typed mergers, normalizers, evidence projectors, validators,
default chains, prompt/profile metadata, and registration coverage consistent
with the D&D family.
- NPC normalization retains its durable behavior after adopting the shared
reconciliation helper.
- The maintained complete example loads and exercises the generated handoff.
- Focused D&D and integration tests pass offline, and current documentation
describes only implemented behavior once the work is complete.

View File

@@ -40,6 +40,7 @@ pipelines:
- spells
- combat-turns
- npc-interactions
- enemy-events
steps:
# Establish session-wide reference artifacts alongside independent item events.
- id: describe-session
@@ -101,3 +102,28 @@ pipelines:
retries: 2
merge: appendorder
normalize: dnd/npc-interactions
- id: track-enemies
references:
npcs:
artifact:
step: describe-session
lane: npcs
scene_descriptions:
artifact:
step: describe-session
lane: scene-descriptions
combat_turns:
artifact:
step: extract-events
lane: combat-turns
npc_interactions:
artifact:
step: extract-events
lane: npc-interactions
artifacts:
enemy-events:
extract:
module: dnd/enemy-events
retries: 2
merge: appendorder
normalize: dnd/enemy-events

View File

@@ -0,0 +1,275 @@
package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/evidencecontext"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
combat "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
enemyevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
itemevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
npcinteractions "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
npcs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
scenedescriptions "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
)
func TestProductionEnemyEventConfigurationResolvesGeneratedHandoffs(t *testing.T) {
components := productionTestComponents(t)
cfg := loadMaintainedExample(t, repositoryPath("examples", "dnd-complete.config.yml"))
effective, err := cfg.Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
if err != nil {
t.Fatalf("Resolve() error = %v, want nil", err)
}
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{
ConfigPath: repositoryPath("examples", "dnd-complete.config.yml"),
WorkingDir: repositoryPath("examples"),
})
if err != nil {
t.Fatalf("MaterializeReferences() error = %v, want nil", err)
}
lane := referenceContractLane(t, materialized, "enemy-events")
if lane.ArtifactKind != dnd.EnemyEventListKind || lane.Extract.Module != enemyevents.Key || lane.Extract.Retries != 2 || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != enemyeventnormalize.Key {
t.Fatalf("enemy event lane = %#v, want typed production composition", lane)
}
for slot, want := range map[string]struct{ step, lane string }{
"npcs": {step: "describe-session", lane: "npcs"},
"scene_descriptions": {step: "describe-session", lane: "scene-descriptions"},
"combat_turns": {step: "extract-events", lane: "combat-turns"},
"npc_interactions": {step: "extract-events", lane: "npc-interactions"},
} {
binding, found := generatedReferenceBinding(lane.ExtractReferences.Bindings, slot)
if !found || binding.Artifact.Step != want.step || binding.Artifact.Lane != want.lane {
t.Fatalf("enemy event %s reference = %#v, want generated %s/%s artifact", slot, binding, want.step, want.lane)
}
}
if binding, found := generatedReferenceBinding(lane.NormalizeReferences.Bindings, "npcs"); !found || binding.Artifact.Step != "describe-session" || binding.Artifact.Lane != "npcs" {
t.Fatalf("enemy event normalizer NPC reference = %#v, want generated NPC artifact", binding)
}
catalog := catalogFromRegistries(components.registries)
extractSpec, ok := catalog.Extractors.Spec(enemyevents.Key)
if !ok || !reflect.DeepEqual(extractSpec.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(extractSpec.Provides, []string{"dnd.enemy_events"}) {
t.Fatalf("enemy event extractor spec = %#v, want source and artifact capabilities", extractSpec)
}
normalizeSpec, ok := catalog.Normalizers.SpecForArtifact(enemyeventnormalize.Key, dnd.EnemyEventListKind)
if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) {
t.Fatalf("enemy event normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec)
}
for _, slot := range []string{"npcs", "scene_descriptions", "combat_turns", "npc_interactions"} {
if !hasReferenceSlot(extractSpec.ReferenceSlots, slot) {
t.Fatalf("enemy event extractor slots = %#v, want %q", extractSpec.ReferenceSlots, slot)
}
}
if !hasReferenceSlot(normalizeSpec.ReferenceSlots, "npcs") {
t.Fatalf("enemy event normalizer slots = %#v, want NPC registry", normalizeSpec.ReferenceSlots)
}
profile := cfg.Pipelines["dnd-session"]
profile.Steps[2].References["npcs"] = pipeline.GeneratedReference("track-enemies", "enemy-events")
cfg.Pipelines["dnd-session"] = profile
if _, err := cfg.Resolve(resolveInputForMaintainedExample(components, "dnd-session")); err == nil || !strings.Contains(err.Error(), "earlier step") {
t.Fatalf("Resolve() error = %v, want future generated-reference rejection", err)
}
}
func TestMaintainedCompleteExampleProducesEnemyEventsThroughGeneratedHandoffs(t *testing.T) {
t.Chdir(repositoryPath())
outputRoot := filepath.Join(t.TempDir(), "output")
configPath := completeExampleConfigWithTemporaryCache(t)
client := &enemyEventLLMClient{}
options := productionCLIOptions(t)
options.Now = func() time.Time { return time.Unix(1700000000, 0).UTC() }
options.RunIDGenerator = func(time.Time) (string, error) { return productionRunID, nil }
options.UserCacheDir = func() (string, error) { return "", errors.New("user cache must not be used") }
options.LLMClientFactory = func(context.Context, config.Config, string, LLMRuntimeOverrides) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
return client, nil, nil
}
var stdout, stderr strings.Builder
code := RunWithOptions([]string{
"run", "dnd-session",
"--config", configPath,
"--input", repositoryPath("examples", "dnd-complete-transcript.json"),
"--chunk_cache", "bypass", "--output-dir", outputRoot, "--session-id", "enemy-event-session",
}, &stdout, &stderr, options)
if code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
runRoot := filepath.Join(outputRoot, productionRunID)
index := readProductionJSON[exampleOutputIndex](t, filepath.Join(runRoot, "index.json"))
var enemyOutput exampleOutputIndexEntry
for _, entry := range index.OutputFiles {
if entry.LaneID == "enemy-events" {
enemyOutput = entry
break
}
}
if enemyOutput.File != "lanes/enemy-events.json" || enemyOutput.SchemaID != "notarius.dnd.enemy_events" || enemyOutput.SchemaVersion != "v1" {
t.Fatalf("enemy event output = %#v, want typed enemy-event JSON", enemyOutput)
}
value := readProductionJSON[dnd.EnemyEventList](t, filepath.Join(runRoot, enemyOutput.File))
if len(value.Events) != 1 || value.Events[0].Name != "Kesh" || value.Events[0].Kind != dnd.EnemyEventKindFled || len(value.Events[0].SourceRefs) != 1 || value.Events[0].SourceRefs[0].SourceID != "session-ravenfall" || value.Events[0].SourceRefs[0].StartUnitID != 10 {
t.Fatalf("enemy event artifact = %#v, want source-linked Kesh fleeing event", value)
}
evidence := readProductionJSON[evidencecontext.Document](t, filepath.Join(runRoot, "evidence-context.json"))
if !containsString(evidence.SelectedLanes, "enemy-events") || !evidenceHasLane(evidence, "enemy-events") {
t.Fatalf("evidence context = %#v, want direct enemy-event evidence", evidence)
}
requests := client.requestsFor(enemyevents.PromptID)
if len(requests) != 1 {
t.Fatalf("enemy event requests = %#v, want only the combat scene request", requests)
}
request := requests[0]
if request.SessionID != "enemy-event-session" {
t.Fatalf("enemy event session = %q, want shared session", request.SessionID)
}
for slot, required := range map[string]string{
"npcs": "Kesh",
"combat_turns": "Kesh",
"npc_interactions": "Kesh",
} {
input, ok := request.Inputs[slot]
if !ok || !strings.Contains(string(input.Content), required) || strings.Contains(string(input.Content), "source_refs") || strings.Contains(string(input.Content), "start_unit_id") {
t.Fatalf("enemy event %s prompt input = %q, want compact source-free grounding", slot, input.Content)
}
}
}
func completeExampleConfigWithTemporaryCache(t *testing.T) string {
t.Helper()
content, err := os.ReadFile(repositoryPath("examples", "dnd-complete.config.yml"))
if err != nil {
t.Fatal(err)
}
cacheRoot := t.TempDir()
updated := strings.Replace(string(content), "directory: ./notarius-cache/chunk-plans", fmt.Sprintf("directory: %q", filepath.Join(cacheRoot, "chunk-plans")), 1)
updated = strings.Replace(updated, "directory: ./notarius-cache/checkpoints", fmt.Sprintf("directory: %q", filepath.Join(cacheRoot, "checkpoints")), 1)
for relative, absolute := range map[string]string{
"./dnd-party.txt": repositoryPath("examples", "dnd-party.txt"),
"./dnd-glossary.txt": repositoryPath("examples", "dnd-glossary.txt"),
"./dnd-spell-catalog.json": repositoryPath("examples", "dnd-spell-catalog.json"),
} {
updated = strings.ReplaceAll(updated, relative, fmt.Sprintf("%q", absolute))
}
path := filepath.Join(t.TempDir(), "dnd-complete.config.yml")
if err := os.WriteFile(path, []byte(updated), 0o600); err != nil {
t.Fatal(err)
}
return path
}
type enemyEventLLMClient struct {
mu sync.Mutex
requests []contracts.StructuredCompletionRequest
}
func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, request contracts.StructuredCompletionRequest, output any) (contracts.StructuredCompletionResponse, error) {
if err := ctx.Err(); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
combatScene := strings.Contains(string(request.Inputs["transcript"].Content), "Roll initiative")
var content []byte
switch request.PromptID {
case scenes.PromptID:
content = []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":6},{"start_unit_id":7,"end_unit_id":11}]}`)
case npcs.PromptID:
if combatScene {
content = []byte(`{"npcs":[{"name":"Kesh","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`)
} else {
content = []byte(`{"npcs":[]}`)
}
case npcnormalize.PromptID:
content = []byte(`{"duplicate_groups":[]}`)
case scenedescriptions.PromptID:
kind, title := "narrative", "Arrival"
if combatScene {
kind, title = "combat", "Raiders attack"
}
content = []byte(fmt.Sprintf(`{"kind":%q,"title":%q,"summary":"session scene"}`, kind, title))
case spells.PromptID:
content = []byte(`{"spell_casts":[]}`)
case itemevents.PromptID:
content = []byte(`{"events":[]}`)
case combat.PromptID:
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`)
case npcinteractions.PromptID:
if combatScene {
content = []byte(`{"interactions":[{"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`)
} else {
content = []byte(`{"interactions":[]}`)
}
case enemyevents.PromptID:
content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`)
default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", request.PromptID)
}
if err := json.Unmarshal(content, output); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate fake structured target: %w", err)
}
client.mu.Lock()
client.requests = append(client.requests, request)
client.mu.Unlock()
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "deterministic", ProfileID: request.ProfileID}, nil
}
func (client *enemyEventLLMClient) requestsFor(promptID string) []contracts.StructuredCompletionRequest {
client.mu.Lock()
defer client.mu.Unlock()
var requests []contracts.StructuredCompletionRequest
for _, request := range client.requests {
if request.PromptID == promptID {
requests = append(requests, request)
}
}
return requests
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func evidenceHasLane(value evidencecontext.Document, laneID string) bool {
for _, context := range value.Contexts {
for _, reference := range context.EvidenceRefs {
if reference.LaneID == laneID {
return true
}
}
}
return false
}
func generatedReferenceBinding(bindings []pipeline.ReferenceBinding, slotName string) (pipeline.ReferenceBinding, bool) {
for _, binding := range bindings {
if binding.SlotName == slotName && binding.Artifact != nil {
return binding, true
}
}
return pipeline.ReferenceBinding{}, false
}

View File

@@ -54,8 +54,8 @@ func TestMaintainedExamplesLoadResolveAndList(t *testing.T) {
t.Fatalf("materialize maintained example references for %q: %v", pipelineID, err)
}
if example.name == "complete" {
if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:item-events,npcs,scene-descriptions|extract-events:combat-turns,npc-interactions,spells" {
t.Fatalf("complete example steps and lanes = %v, want every D&D extractor in the documented two-step composition", got)
if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:item-events,npcs,scene-descriptions|extract-events:combat-turns,npc-interactions,spells|track-enemies:enemy-events" {
t.Fatalf("complete example steps and lanes = %v, want the documented D&D extractor composition", got)
}
spellLane := referenceContractLane(t, materialized, "spells")
if len(spellLane.ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 ||
@@ -71,6 +71,18 @@ func TestMaintainedExamplesLoadResolveAndList(t *testing.T) {
t.Fatalf("item event lane unexpectedly depends on generated scene descriptions: %#v", itemEventLane)
}
}
enemyEventLane := referenceContractLane(t, materialized, "enemy-events")
for slot, want := range map[string]struct{ step, lane string }{
"npcs": {step: "describe-session", lane: "npcs"},
"scene_descriptions": {step: "describe-session", lane: "scene-descriptions"},
"combat_turns": {step: "extract-events", lane: "combat-turns"},
"npc_interactions": {step: "extract-events", lane: "npc-interactions"},
} {
binding, found := generatedReferenceBinding(enemyEventLane.ExtractReferences.Bindings, slot)
if !found || binding.Artifact.Step != want.step || binding.Artifact.Lane != want.lane {
t.Fatalf("enemy event %s reference = %#v, want generated %s/%s artifact", slot, binding, want.step, want.lane)
}
}
}
}
var stdout, stderr strings.Builder

View File

@@ -29,12 +29,15 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
enemyeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/enemyevents"
itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents"
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
@@ -47,9 +50,9 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
assertProductionContains(t, "inputs", registries.Inputs.RegisteredKeys(), []string{"seriatim"})
assertProductionContains(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes", "generic"})
assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", "dnd/npcs", combatextract.Key, itemeventextract.Key})
assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", "dnd/npcs", combatextract.Key, itemeventextract.Key, enemyeventextract.Key})
assertProductionContains(t, "mergers", registries.Mergers.RegisteredKeys(), []string{"appendorder"})
assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key, "dnd/npcs", combatnormalize.Key, itemeventnormalize.Key})
assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key, "dnd/npcs", combatnormalize.Key, itemeventnormalize.Key, enemyeventnormalize.Key})
assertProductionContains(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"})
assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{
"extract/dnd/spells/catalog",
@@ -64,17 +67,23 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
"extract/dnd/item-events/source_refs",
"extract/dnd/item-events/source_relatedness",
"normalize/dnd/item-events/invariants",
"extract/dnd/enemy-events/shape",
"extract/dnd/enemy-events/engagements",
"extract/dnd/enemy-events/source_refs",
"extract/dnd/enemy-events/source_relatedness",
"normalize/dnd/enemy-events/invariants",
"generic/always_accept",
"generic/always_reject",
"generic/valid_json",
"generic/valid_json_schema",
})
assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind})
assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind})
assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind})
assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind})
assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind})
assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.EnemyEventListKind})
assertProductionContains(t, "spell normalizer variants", registries.Normalizers.RegisteredArtifactKinds(spellnormalize.Key), []contracts.ArtifactKind{dnd.SpellListKind})
assertProductionContains(t, "combat normalizer variants", registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind})
assertProductionContains(t, "item event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind})
assertProductionContains(t, "enemy event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind})
wantChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
@@ -132,6 +141,17 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, itemeventnormalize.Key); !reflect.DeepEqual(got, itemEventNormalizeChain) {
t.Fatalf("item event normalize validator chain = %#v, want %#v", got, itemEventNormalizeChain)
}
enemyEventExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/enemy-events/shape"),
pipeline.Binding("extract/dnd/enemy-events/engagements"),
pipeline.Binding("extract/dnd/enemy-events/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/enemy-events/source_relatedness"),
}
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, enemyeventextract.Key); !reflect.DeepEqual(got, enemyEventExtractChain) {
t.Fatalf("enemy event extract validator chain = %#v, want %#v", got, enemyEventExtractChain)
}
assetNames := productionAssetNames(t, components.assets.PromptFS)
requiredAssets := []string{
@@ -162,6 +182,10 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
"dnd.item_events/sharedassets/common-dnd-system.md",
"dnd.item_events/sharedassets/common-dnd-transcript.md",
"dnd.item_events/task.md",
"dnd.enemy_events/dnd.enemy_events.yaml",
"dnd.enemy_events/grounding.md",
"dnd.enemy_events/instructions.md",
"dnd.enemy_events/task.md",
}
assertProductionContains(t, "production prompt assets", assetNames, requiredAssets)
@@ -180,6 +204,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
{stage: pipeline.StageExtract, key: "dnd/item-events", want: contracts.ExecutionClassLLMBacked},
{stage: pipeline.StageExtract, key: "dnd/npc-interactions", want: contracts.ExecutionClassLLMBacked},
{stage: pipeline.StageExtract, key: "dnd/scene-descriptions", want: contracts.ExecutionClassLLMBacked},
{stage: pipeline.StageExtract, key: enemyeventextract.Key, want: contracts.ExecutionClassLLMBacked},
{stage: pipeline.StageMerge, key: "appendorder", want: contracts.ExecutionClassDeterministic},
{stage: pipeline.StageNormalize, key: "noop", want: contracts.ExecutionClassDeterministic},
{stage: pipeline.StageNormalize, key: "dnd/spells", want: contracts.ExecutionClassDeterministic},
@@ -188,6 +213,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
{stage: pipeline.StageNormalize, key: "dnd/item-events", want: contracts.ExecutionClassDeterministic},
{stage: pipeline.StageNormalize, key: "dnd/npc-interactions", want: contracts.ExecutionClassDeterministic},
{stage: pipeline.StageNormalize, key: "dnd/scene-descriptions", want: contracts.ExecutionClassDeterministic},
{stage: pipeline.StageNormalize, key: enemyeventnormalize.Key, want: contracts.ExecutionClassDeterministic},
{stage: pipeline.StageOutput, key: "json", want: contracts.ExecutionClassDeterministic},
} {
got, ok := catalog.ExecutionClass(test.stage, test.key)
@@ -211,6 +237,10 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
if !ok || itemEventCodecSpec.Kind != dnd.ItemEventListKind || itemEventCodecSpec.Schema.ID != itemeventcodec.SchemaID {
t.Fatalf("item event codec spec = %#v, ok=%t, want typed D&D item-event codec", itemEventCodecSpec, ok)
}
enemyEventCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.EnemyEventListKind)
if !ok || enemyEventCodecSpec.Kind != dnd.EnemyEventListKind || enemyEventCodecSpec.Schema.ID != enemyeventcodec.SchemaID {
t.Fatalf("enemy event codec spec = %#v, ok=%t, want typed D&D enemy-event codec", enemyEventCodecSpec, ok)
}
if got := catalog.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
t.Fatalf("catalog validator chain = %#v, want %#v", got, wantChain)
}

View File

@@ -0,0 +1,35 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.dnd.enemy_events",
"type": "object",
"additionalProperties": false,
"required": ["events"],
"properties": {
"events": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "kind", "source_refs"],
"properties": {
"name": {"type": "string", "minLength": 1},
"kind": {"type": "string", "enum": ["engaged", "killed", "fled", "captured", "incapacitated"]},
"source_refs": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["source_id", "start_unit_id", "end_unit_id"],
"properties": {
"source_id": {"type": "string", "minLength": 1},
"start_unit_id": {"type": "integer", "minimum": 1},
"end_unit_id": {"type": "integer", "minimum": 1}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,182 @@
// Package enemyevents encodes durable D&D enemy-event artifacts.
package enemyevents
import (
"embed"
"encoding/json"
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"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 (
SchemaID = "notarius.dnd.enemy_events"
SchemaName = "notarius_dnd_enemy_events_v1"
SchemaVersion = "v1"
MediaType = "application/json"
)
//go:embed assets/schemas/dnd_enemy_events.v1.json
var schemaAssets embed.FS
var _ contracts.ArtifactCodec[dnd.EnemyEventList] = (*Codec)(nil)
type Codec struct{}
func New() *Codec { return &Codec{} }
func (c *Codec) Kind() contracts.ArtifactKind { return dnd.EnemyEventListKind }
func (c *Codec) Schema() contracts.ArtifactSchema {
raw, err := schemaAssets.ReadFile("assets/schemas/dnd_enemy_events.v1.json")
if err != nil {
return contracts.ArtifactSchema{}
}
return contracts.ArtifactSchema{
ID: SchemaID,
Name: SchemaName,
Version: SchemaVersion,
JSONSchema: append([]byte(nil), raw...),
}
}
func (c *Codec) MediaType() string { return MediaType }
func (c *Codec) Metadata(value dnd.EnemyEventList) map[string]any {
return map[string]any{"event_count": len(value.Events)}
}
func (c *Codec) Encode(value dnd.EnemyEventList) ([]byte, error) {
if err := validateRequiredValueFields(value); err != nil {
return nil, fmt.Errorf("encode dnd enemy event list: %w", err)
}
return c.EncodeCandidate(value)
}
// EncodeCandidate provides the durable representation before semantic
// validators have approved a value.
func (c *Codec) EncodeCandidate(value dnd.EnemyEventList) ([]byte, error) {
return candidatejson.EncodeCandidate("dnd enemy event list", cloneList(value))
}
func (c *Codec) Decode(content []byte) (dnd.EnemyEventList, error) {
value, err := c.DecodeCandidate(content)
if err != nil {
return dnd.EnemyEventList{}, err
}
if err := validateRequiredJSONFields(content); err != nil {
return dnd.EnemyEventList{}, fmt.Errorf("decode dnd enemy event list: %w", err)
}
return value, nil
}
// DecodeCandidate reads one strict durable JSON value before semantic
// validators have approved it.
func (c *Codec) DecodeCandidate(content []byte) (dnd.EnemyEventList, error) {
value, err := candidatejson.DecodeCandidate[dnd.EnemyEventList]("dnd enemy event list", content)
if err != nil {
return dnd.EnemyEventList{}, err
}
return cloneList(value), nil
}
func validateRequiredValueFields(value dnd.EnemyEventList) error {
if value.Events == nil {
return fmt.Errorf("events must be present")
}
for index, event := range value.Events {
if event.SourceRefs == nil {
return fmt.Errorf("events[%d].source_refs must be present", index)
}
}
return nil
}
func validateRequiredJSONFields(content []byte) error {
var root map[string]json.RawMessage
if err := json.Unmarshal(content, &root); err != nil || root == nil {
return fmt.Errorf("must be a JSON object")
}
events, err := requiredArray(root, "events", "")
if err != nil {
return err
}
for eventIndex, rawEvent := range events {
path := fmt.Sprintf("events[%d]", eventIndex)
var event map[string]json.RawMessage
if err := json.Unmarshal(rawEvent, &event); err != nil || event == nil {
return fmt.Errorf("%s must be an object", path)
}
for _, field := range []string{"name", "kind"} {
if err := requireField(event, field, path); err != nil {
return err
}
}
refs, err := requiredArray(event, "source_refs", path)
if err != nil {
return err
}
for refIndex, rawRef := range refs {
refPath := fmt.Sprintf("%s.source_refs[%d]", path, refIndex)
var ref map[string]json.RawMessage
if err := json.Unmarshal(rawRef, &ref); err != nil || ref == nil {
return fmt.Errorf("%s must be an object", refPath)
}
for _, field := range []string{"source_id", "start_unit_id", "end_unit_id"} {
if err := requireField(ref, field, refPath); err != nil {
return err
}
}
}
}
return nil
}
func requiredArray(object map[string]json.RawMessage, field, path string) ([]json.RawMessage, error) {
raw, err := requiredField(object, field, path)
if err != nil {
return nil, err
}
var values []json.RawMessage
if err := json.Unmarshal(raw, &values); err != nil {
return nil, fmt.Errorf("%s must be an array", fieldPath(path, field))
}
return values, nil
}
func requireField(object map[string]json.RawMessage, field, path string) error {
_, err := requiredField(object, field, path)
return err
}
func requiredField(object map[string]json.RawMessage, field, path string) (json.RawMessage, error) {
raw, ok := object[field]
if !ok || string(raw) == "null" {
return nil, fmt.Errorf("%s must be present", fieldPath(path, field))
}
return raw, nil
}
func fieldPath(path, field string) string {
if path == "" {
return field
}
return path + "." + field
}
func cloneList(value dnd.EnemyEventList) dnd.EnemyEventList {
if value.Events == nil {
return dnd.EnemyEventList{}
}
cloned := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, len(value.Events))}
for index, event := range value.Events {
cloned.Events[index] = event
if event.SourceRefs != nil {
cloned.Events[index].SourceRefs = append([]source.SourceRef(nil), event.SourceRefs...)
}
}
return cloned
}

View File

@@ -0,0 +1,99 @@
package enemyevents
import (
"encoding/json"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
func validList() dnd.EnemyEventList {
return dnd.EnemyEventList{Events: []dnd.EnemyEvent{
{Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: refs(1, 2)},
{Name: "Ashfang", Kind: dnd.EnemyEventKindFled, SourceRefs: refs(5, 6)},
}}
}
func refs(start, end int) []source.SourceRef {
return []source.SourceRef{{SourceID: "session", StartUnitID: start, EndUnitID: end}}
}
func TestCodecRoundTripAndIdentity(t *testing.T) {
codec := New()
value := validList()
content, err := codec.Encode(value)
if err != nil {
t.Fatalf("Encode() error = %v", err)
}
decoded, err := codec.Decode(content)
if err != nil || !reflect.DeepEqual(decoded, value) {
t.Fatalf("Decode() = %#v, %v; want %#v", decoded, err, value)
}
schema := codec.Schema()
if codec.Kind() != dnd.EnemyEventListKind || codec.MediaType() != MediaType || schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) {
t.Fatalf("codec identity/schema = %q/%q %#v", codec.Kind(), codec.MediaType(), schema)
}
registry := pipeline.NewArtifactCodecRegistry()
if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil {
t.Fatal(err)
}
spec, ok := registry.Spec(dnd.EnemyEventListKind)
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
t.Fatalf("registered spec = %#v, %t", spec, ok)
}
}
func TestCodecRejectsStrictJSONAndMissingRequiredFields(t *testing.T) {
validJSON := `{"events":[{"name":"Ashfang","kind":"engaged","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
tests := []struct {
name, raw, want string
}{
{"invalid JSON", `{`, "decode dnd enemy event list"},
{"unknown root field", `{"events":[],"unexpected":true}`, "unknown field"},
{"unknown event field", strings.Replace(validJSON, `"kind":"engaged"`, `"kind":"engaged","unexpected":true`, 1), "unknown field"},
{"missing events", `{}`, "events must be present"},
{"missing name", strings.Replace(validJSON, `"name":"Ashfang",`, "", 1), "events[0].name must be present"},
{"missing kind", strings.Replace(validJSON, `"kind":"engaged",`, "", 1), "events[0].kind must be present"},
{"missing refs", strings.Replace(validJSON, `,"source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]`, "", 1), "events[0].source_refs must be present"},
{"missing source ID", strings.Replace(validJSON, `"source_id":"session",`, "", 1), "source_refs[0].source_id must be present"},
{"trailing JSON", `{"events":[]} {}`, "multiple JSON values"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := New().Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Decode() error = %v, want %q", err, test.want)
}
})
}
}
func TestCodecDefensivelyOwnsValuesAndDefersSemanticValidation(t *testing.T) {
codec := New()
candidate := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{
Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{SourceID: "", StartUnitID: 0, EndUnitID: -1}},
}}}
content, err := codec.EncodeCandidate(candidate)
if err != nil {
t.Fatal(err)
}
decoded, err := codec.Decode(content)
if err != nil || !reflect.DeepEqual(decoded, candidate) {
t.Fatalf("Decode() = %#v, %v; want semantic candidate preservation", decoded, err)
}
decoded.Events[0].SourceRefs[0].SourceID = "changed"
if candidate.Events[0].SourceRefs[0].SourceID != "" {
t.Fatal("Decode() retained caller-owned source references")
}
first := codec.Schema()
first.JSONSchema[0] = '['
if second := codec.Schema(); !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' {
t.Fatal("Schema() returned shared bytes")
}
}

View File

@@ -0,0 +1,107 @@
// Package enemyevents owns canonical ordering and identity policy for D&D
// enemy-event artifacts.
package enemyevents
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"
)
// NormalizeDisplay returns the durable display form for an enemy subject.
func NormalizeDisplay(value string) string { return identity.NormalizeDisplay(value) }
// ComparisonKey returns the shared D&D identity key for an enemy subject.
func ComparisonKey(value string) string { return identity.ComparisonKey(NormalizeDisplay(value)) }
// SupportedKind reports whether kind belongs to the durable enemy-event vocabulary.
func SupportedKind(kind dnd.EnemyEventKind) bool {
_, ok := KindRank(kind)
return ok
}
// KindRank returns the explicit chronological tie-break order for event kinds.
func KindRank(kind dnd.EnemyEventKind) (int, bool) {
switch kind {
case dnd.EnemyEventKindEngaged:
return 0, true
case dnd.EnemyEventKindIncapacitated:
return 1, true
case dnd.EnemyEventKindCaptured:
return 2, true
case dnd.EnemyEventKindFled:
return 3, true
case dnd.EnemyEventKindKilled:
return 4, true
default:
return 0, false
}
}
// SourceRefsEqual reports whether two reference sequences are equal after
// canonical ordering and exact duplicate removal.
func SourceRefsEqual(order shared.SourceRefOrder, left, right []source.SourceRef) bool {
left = order.Canonicalize(left)
right = order.Canonicalize(right)
if (left == nil) != (right == nil) || len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
// ExactEqual reports whether events have the same subject identity, kind, and
// complete canonical evidence sequence.
func ExactEqual(order shared.SourceRefOrder, left, right dnd.EnemyEvent) bool {
return ComparisonKey(left.Name) == ComparisonKey(right.Name) &&
left.Kind == right.Kind &&
SourceRefsEqual(order, left.SourceRefs, right.SourceRefs)
}
// Less defines the canonical stable event order. Invalid source references
// remain comparable through SourceRefOrder's literal fallback so malformed
// candidates can still be sorted for later validation.
func Less(order shared.SourceRefOrder, left, right dnd.EnemyEvent) bool {
leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs)
rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs)
if leftHasEvidence != rightHasEvidence {
return leftHasEvidence
}
if leftHasEvidence && leftPosition != rightPosition {
return leftPosition < rightPosition
}
if leftKey, rightKey := ComparisonKey(left.Name), ComparisonKey(right.Name); leftKey != rightKey {
return leftKey < rightKey
}
if leftName, rightName := NormalizeDisplay(left.Name), NormalizeDisplay(right.Name); leftName != rightName {
return leftName < rightName
}
if leftRank, leftKnown := KindRank(left.Kind); leftKnown {
if rightRank, rightKnown := KindRank(right.Kind); rightKnown && leftRank != rightRank {
return leftRank < rightRank
} else if !rightKnown {
return true
}
} else if _, rightKnown := KindRank(right.Kind); rightKnown {
return false
}
if left.Kind != right.Kind {
return left.Kind < right.Kind
}
return sourceRefsLess(order, order.Canonicalize(left.SourceRefs), order.Canonicalize(right.SourceRefs))
}
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 order.Less(left[index], right[index])
}
return len(left) < len(right)
}

View File

@@ -0,0 +1,90 @@
package enemyevents
import (
"sort"
"testing"
"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 TestSupportedKindAndRank(t *testing.T) {
tests := []struct {
kind dnd.EnemyEventKind
rank int
}{
{dnd.EnemyEventKindEngaged, 0},
{dnd.EnemyEventKindIncapacitated, 1},
{dnd.EnemyEventKindCaptured, 2},
{dnd.EnemyEventKindFled, 3},
{dnd.EnemyEventKindKilled, 4},
}
for _, test := range tests {
if rank, ok := KindRank(test.kind); !ok || rank != test.rank || !SupportedKind(test.kind) {
t.Fatalf("kind %q = (%d, %t), supported %t", test.kind, rank, ok, SupportedKind(test.kind))
}
}
if _, ok := KindRank("unknown"); ok || SupportedKind("unknown") {
t.Fatal("unsupported kind was accepted")
}
}
func TestLessOrdersChronologyThenKind(t *testing.T) {
order := testOrder()
events := []dnd.EnemyEvent{
{Name: "Ashfang", Kind: dnd.EnemyEventKindKilled, SourceRefs: refs(20)},
{Name: "Ashfang", Kind: dnd.EnemyEventKindFled, SourceRefs: refs(20)},
{Name: "Ashfang", Kind: dnd.EnemyEventKindCaptured, SourceRefs: refs(20)},
{Name: "Ashfang", Kind: dnd.EnemyEventKindIncapacitated, SourceRefs: refs(20)},
{Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: refs(20)},
{Name: "Later", Kind: dnd.EnemyEventKindEngaged, SourceRefs: refs(30)},
{Name: "Earlier", Kind: dnd.EnemyEventKindKilled, SourceRefs: refs(10)},
}
sort.SliceStable(events, func(left, right int) bool { return Less(order, events[left], events[right]) })
want := []dnd.EnemyEventKind{
dnd.EnemyEventKindKilled,
dnd.EnemyEventKindEngaged,
dnd.EnemyEventKindIncapacitated,
dnd.EnemyEventKindCaptured,
dnd.EnemyEventKindFled,
dnd.EnemyEventKindKilled,
dnd.EnemyEventKindEngaged,
}
for index, kind := range want {
if events[index].Kind != kind {
t.Fatalf("event %d kind = %q, want %q", index, events[index].Kind, kind)
}
}
}
func TestExactEqualUsesSubjectIdentityAndCanonicalEvidence(t *testing.T) {
order := testOrder()
first := dnd.EnemyEvent{Name: " ASHFANG ", Kind: dnd.EnemyEventKindFled, SourceRefs: []source.SourceRef{
{SourceID: "session", StartUnitID: 30, EndUnitID: 30},
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
}}
second := dnd.EnemyEvent{Name: "Ashfang", Kind: dnd.EnemyEventKindFled, SourceRefs: []source.SourceRef{
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
{SourceID: "session", StartUnitID: 30, EndUnitID: 30},
}}
if !ExactEqual(order, first, second) || !SourceRefsEqual(order, first.SourceRefs, second.SourceRefs) {
t.Fatal("canonical duplicate identity was not recognized")
}
differentKind := second
differentKind.Kind = dnd.EnemyEventKindKilled
differentEvidence := second
differentEvidence.SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}
if ExactEqual(order, first, differentKind) || ExactEqual(order, first, differentEvidence) {
t.Fatal("distinct event was treated as an exact duplicate")
}
}
func refs(unitID int) []source.SourceRef {
return []source.SourceRef{{SourceID: "session", StartUnitID: unitID, EndUnitID: unitID}}
}
func testOrder() shared.SourceRefOrder {
return shared.NewSourceRefOrder(&source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}})
}

View File

@@ -0,0 +1,6 @@
package enemyevents
import "embed"
//go:embed assets/schemas/dnd_enemy_events_llm.v1.json assets/prompts/*.yaml assets/prompts/*.md
var embeddedAssets embed.FS

View File

@@ -0,0 +1,55 @@
id: dnd.enemy_events
version: "v1"
default_profile: dnd-extraction
inputs:
- name: transcript
required: true
content_type: application/json
- name: players
required: false
content_type: text/plain
- name: party
required: false
content_type: text/plain
- name: glossary
required: false
content_type: text/plain
- name: npcs
required: true
content_type: application/json
- name: combat_turns
required: true
content_type: application/json
- name: npc_interactions
required: true
content_type: application/json
messages:
- role: system
content_file: ./sharedassets/common-dnd-system.md
- role: user
content_file: ./sharedassets/common-dnd-identity.md
- role: user
content_file: ./sharedassets/common-dnd-references.md
cache_control:
type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-transcript.md
cache_control:
type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-extraction-evidence.md
- role: user
content_file: ./sharedassets/common-dnd-npcs.md
- role: user
content_file: ./grounding.md
- role: user
content_file: ./task.md
- role: user
content_file: ./instructions.md
cache_control:
type: ephemeral
output:
format: json
validation_mode: json_schema
schema_path: dnd_enemy_events_llm.v1.json
repair_attempts: 0

View File

@@ -0,0 +1,12 @@
Compact combat grounding is supplied below. It can guide attention and
disambiguation, but it is not evidence. Do not derive an event, subject,
outcome, or source range from either list. The current transcript alone must
directly establish every returned event.
Combat-turn grounding:
{{ input "combat_turns" }}
Named combat-opponent grounding:
{{ input "npc_interactions" }}

View File

@@ -0,0 +1,14 @@
Exclude party members, allies, neutral observers, mentioned-but-absent enemies,
hazards, traps, environmental effects, uncertain allegiance, table talk,
planning, hypotheses, recaps outside this passage, and downstream inference.
Do not infer an engagement or outcome from initiative, turn absence, damage,
defeat, movement, a scene ending, combat-opponent grounding, or any auxiliary
artifact. Auxiliary inputs can guide attention but cannot prove or supply an
event. Cite only narrow current-transcript ranges that establish each event.
Return the `events` array even when no enemy event is established. Every event
must contain only `name`, `kind`, and `source_refs`. Use exactly one kind:
`engaged`, `killed`, `fled`, `captured`, or `incapacitated`. Each source range
uses integer `start_unit_id` and `end_unit_id`; omit `source_id` because
Notarius assigns the current source identity.

View File

@@ -0,0 +1,20 @@
Extract Dungeons & Dragons enemy events from the supplied combat transcript.
Return an `engaged` event only when the transcript directly establishes that a
subject is actively opposing the party in combat. Return `killed`, `fled`,
`captured`, or `incapacitated` only when the transcript explicitly establishes
that outcome. An outcome may share evidence with an engagement, and a later
engagement or outcome for the same subject remains a separate observation.
Emit at most one engagement for the same subject in this combat scene.
For `killed`, direct death or killing is required. For `fled`, the subject must
explicitly escape, retreat, or leave combat to avoid continued engagement. For
`captured`, the subject must be explicitly taken prisoner or secured under the
party's control. For `incapacitated`, the subject must be explicitly unable to
continue acting without being established as killed or captured.
Use a normalized NPC registry spelling when the transcript identifies that
named NPC. A hostile creature without a registry entry is allowed. For unnamed
individuals or groups, use only the narrowest transcript-grounded label, such
as `Orcs`, `One orc`, or `Remaining orcs`; never invent member names, IDs, or
quantities.

View File

@@ -0,0 +1,33 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.dnd.enemy_events.llm",
"type": "object",
"additionalProperties": false,
"required": ["events"],
"properties": {
"events": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "kind", "source_refs"],
"properties": {
"name": {"type": "string"},
"kind": {"type": "string"},
"source_refs": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["start_unit_id", "end_unit_id"],
"properties": {
"start_unit_id": {"type": "integer"},
"end_unit_id": {"type": "integer"}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,210 @@
package enemyevents
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
sceneregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/scenedescriptions/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const (
Key = "dnd/enemy-events"
mappingPolicy = "dnd.enemy_events.extract_mapping.v1"
sceneGatePolicy = "dnd.enemy_events.scene_gate.v1"
)
var requiredCapabilities = []string{
"chunks",
"source.transcript",
}
var providedCapabilities = []string{
"dnd.enemy_events",
}
var _ contracts.Extractor[dnd.EnemyEventList] = (*Extractor)(nil)
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
type Options struct{}
type Extractor struct {
llm contracts.StructuredLLMClient
grounding *groundingResolver
promptSHA string
responseSchemaSHA string
}
func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contracts.ReferenceSet) (*Extractor, error) {
if llmClient == nil {
return nil, extractorErrorf("LLM client must not be nil")
}
if len(references) > 1 {
return nil, extractorErrorf("at most one reference set may be supplied")
}
var referenceSet contracts.ReferenceSet
if len(references) == 1 {
referenceSet = references[0]
}
grounding, err := newGroundingResolver(referenceSet)
if err != nil {
return nil, extractorErrorf("prepare grounding: %w", err)
}
promptSHA, err := promptAssetMetadata()
if err != nil {
return nil, extractorErrorf("load prompt metadata: %w", err)
}
responseSchema, err := loadResponseSchema()
if err != nil {
return nil, extractorErrorf("load response schema: %w", err)
}
return &Extractor{
llm: llmClient,
grounding: grounding,
promptSHA: promptSHA,
responseSchemaSHA: responseSchema.SHA256,
}, nil
}
func (e *Extractor) Key() string { return Key }
func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() }
func (e *Extractor) ManifestMetadata() map[string]any {
if e == nil {
return nil
}
return map[string]any{
"prompt_id": PromptID,
"prompt_version": SchemaVersion,
"prompt_sha256": e.promptSHA,
"mapping_policy": mappingPolicy,
"scene_gate_policy": sceneGatePolicy,
"response_schema_key": string(ResponseSchemaKey),
"response_schema_id": ResponseSchemaID,
"response_schema_name": ResponseSchemaName,
"response_schema_version": SchemaVersion,
"response_schema_sha256": e.responseSchemaSHA,
}
}
func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if e == nil {
return nil
}
return []pipeline.CheckpointFingerprint{
{Name: "prompt", Value: e.promptSHA},
{Name: "response_schema", Value: e.responseSchemaSHA},
{Name: "mapping_policy", Value: mappingPolicy},
{Name: "scene_gate_policy", Value: sceneGatePolicy},
}
}
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.EnemyEventList], error) {
if e == nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("extractor must not be nil")
}
if e.llm == nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("LLM client must not be nil")
}
if e.grounding == nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("grounding resolver must not be nil")
}
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
if err != nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("%w", err)
}
match, err := e.grounding.SceneMatch(req.References, req.Chunk)
if err != nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("resolve scene eligibility: %w", err)
}
switch match.State {
case sceneregistry.MatchExact:
if match.Kind != dnd.SceneKindCombat {
return emptyResult(), nil
}
case sceneregistry.MatchMissing, sceneregistry.MatchMismatched:
return unavailableSceneResult(), nil
default:
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("unsupported scene eligibility match state %q", match.State)
}
grounding, err := e.grounding.Resolve(req.References)
if err != nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("resolve enemy-event grounding: %w", err)
}
inputs := shared.PromptInputs(sourceInput, req.References)
for name, input := range grounding.PromptInputs() {
inputs[name] = input
}
var response extractionResponse
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key,
PromptID: PromptID,
PromptVersion: SchemaVersion,
ProfileID: req.LLMProfile,
SessionID: req.SessionID,
Inputs: inputs,
}, &response); err != nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, extractorErrorf("complete structured output: %w", err)
}
return contracts.TypedExtractionResult[dnd.EnemyEventList]{
Value: canonicalEnemyEventList(response, shared.NewSourceRefOrder(req.Source), req.Source.ID),
}, nil
}
func emptyResult() contracts.TypedExtractionResult[dnd.EnemyEventList] {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{Value: dnd.EnemyEventList{Events: []dnd.EnemyEvent{}}}
}
func unavailableSceneResult() contracts.TypedExtractionResult[dnd.EnemyEventList] {
result := emptyResult()
result.Warnings = []contracts.Warning{{
Scope: SceneDescriptionReferenceSlot,
ReasonCode: "scene_classification_unavailable",
Message: "No exact scene classification was available; enemy-event extraction was skipped.",
}}
return result
}
func ModuleSpec() pipeline.ModuleSpec {
return pipeline.ModuleSpec{
Key: Key,
Stage: pipeline.StageExtract,
ExecutionClass: contracts.ExecutionClassLLMBacked,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.EnemyEventListKind,
ReferenceSlots: referenceSlots(),
}
}
func Register(registry *pipeline.ExtractorRegistry) error {
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.EnemyEventList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(request.Dependencies.LLM, options, request.References)
})
}
func validateOptions(options map[string]any) error {
_, err := DecodeOptions(options)
return err
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, extractorErrorf("%w", err)
}
return Options{}, nil
}
func extractorErrorf(format string, args ...any) error {
return fmt.Errorf("dnd enemy events extractor: "+format, args...)
}

View File

@@ -0,0 +1,229 @@
package enemyevents
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
func TestExtractMapsEnemyEventsInSourceOrder(t *testing.T) {
client := &fakeEnemyEventsLLMClient{response: extractionResponse{Events: []enemyEventResponse{
{Name: "Ashfang", Kind: "killed", SourceRefs: []enemySourceRefResponse{{StartUnitID: 4, EndUnitID: 4}}},
{Name: "Ashfang", Kind: "engaged", SourceRefs: []enemySourceRefResponse{{StartUnitID: 1, EndUnitID: 1}, {StartUnitID: 1, EndUnitID: 1}}},
{Name: "Orcs", Kind: "fled", SourceRefs: []enemySourceRefResponse{{StartUnitID: 3, EndUnitID: 3}}},
{Name: "One orc", Kind: "captured", SourceRefs: []enemySourceRefResponse{{StartUnitID: 2, EndUnitID: 2}}},
{Name: "Ashfang", Kind: "incapacitated", SourceRefs: []enemySourceRefResponse{{StartUnitID: 2, EndUnitID: 2}}},
}}}
result, err := newEnemyExtractor(t, client).Extract(context.Background(), enemyExtractionRequest(t))
if err != nil {
t.Fatal(err)
}
if got := []dnd.EnemyEventKind{result.Value.Events[0].Kind, result.Value.Events[1].Kind, result.Value.Events[2].Kind, result.Value.Events[3].Kind, result.Value.Events[4].Kind}; !reflect.DeepEqual(got, []dnd.EnemyEventKind{
dnd.EnemyEventKindEngaged,
dnd.EnemyEventKindCaptured,
dnd.EnemyEventKindIncapacitated,
dnd.EnemyEventKindFled,
dnd.EnemyEventKindKilled,
}) {
t.Fatalf("event order = %#v", got)
}
if refs := result.Value.Events[0].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}) {
t.Fatalf("canonical source refs = %#v", refs)
}
if len(client.requests) != 1 {
t.Fatalf("LLM calls = %d, want 1", len(client.requests))
}
request := client.requests[0]
if request.StageName != Key || request.PromptID != PromptID || request.PromptVersion != SchemaVersion || request.ProfileID != "enemy-profile" || request.SessionID != "session-123" {
t.Fatalf("LLM request identity = %#v", request)
}
if got := string(request.Inputs[CombatTurnReferenceSlot].Content); !strings.Contains(got, `"actor":"Ashfang"`) || strings.Contains(got, "source_ref") {
t.Fatalf("combat grounding = %s", got)
}
if got := string(request.Inputs[NPCInteractionReferenceSlot].Content); !strings.Contains(got, `"kind":"combat_opponent"`) || strings.Contains(got, "Aria") {
t.Fatalf("interaction grounding = %s", got)
}
}
func TestExtractPreservesSemanticCandidatesAndResponseOwnership(t *testing.T) {
client := &fakeEnemyEventsLLMClient{response: extractionResponse{Events: []enemyEventResponse{{
Name: " ", Kind: "unsupported", SourceRefs: []enemySourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
}}}}
result, err := newEnemyExtractor(t, client).Extract(context.Background(), enemyExtractionRequest(t))
if err != nil {
t.Fatal(err)
}
event := result.Value.Events[0]
if event.Name != " " || event.Kind != "unsupported" || event.SourceRefs[0] != (source.SourceRef{SourceID: "combat-session", StartUnitID: 99}) {
t.Fatalf("semantic candidate = %#v", event)
}
result.Value.Events[0].SourceRefs[0].StartUnitID = 7
if client.response.Events[0].SourceRefs[0].StartUnitID != 99 {
t.Fatal("mapped event aliases model-owned source references")
}
}
func TestExtractSkipsModelForIneligibleScenes(t *testing.T) {
for _, test := range []struct {
name string
kind dnd.SceneKind
change func(*source.Chunk)
wantWarning bool
}{
{name: "non-combat", kind: dnd.SceneKindNarrative},
{name: "missing", kind: dnd.SceneKindCombat, change: func(chunk *source.Chunk) { chunk.ID = "other" }, wantWarning: true},
{name: "mismatched", kind: dnd.SceneKindCombat, change: func(chunk *source.Chunk) { chunk.Ref.EndUnitID++ }, wantWarning: true},
} {
t.Run(test.name, func(t *testing.T) {
client := &fakeEnemyEventsLLMClient{}
request := enemyExtractionRequest(t)
request.References = groundingReferences(t, "Ashfang", test.kind)
if test.change != nil {
test.change(request.Chunk)
}
result, err := newEnemyExtractor(t, client).Extract(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if len(client.requests) != 0 || result.Value.Events == nil || len(result.Value.Events) != 0 {
t.Fatalf("result = %#v, calls = %d", result, len(client.requests))
}
if test.wantWarning {
if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != "scene_classification_unavailable" || result.Warnings[0].Scope != SceneDescriptionReferenceSlot {
t.Fatalf("warnings = %#v", result.Warnings)
}
} else if len(result.Warnings) != 0 {
t.Fatalf("warnings = %#v", result.Warnings)
}
})
}
}
func TestExtractReportsRequiredGroundingAndProviderFailures(t *testing.T) {
client := &fakeEnemyEventsLLMClient{}
request := enemyExtractionRequest(t)
request.References = withoutSlot(request.References, NPCRegistryReferenceSlot)
if _, err := (&Extractor{llm: client, grounding: mustGroundingResolver(t, request.References)}).Extract(context.Background(), request); err == nil || !strings.Contains(err.Error(), "NPC registry") {
t.Fatalf("Extract() error = %v, want required grounding context", err)
}
provider := &fakeEnemyEventsLLMClient{err: errors.New("provider unavailable")}
if _, err := newEnemyExtractor(t, provider).Extract(context.Background(), enemyExtractionRequest(t)); err == nil || !strings.Contains(err.Error(), "complete structured output") || !strings.Contains(err.Error(), "provider unavailable") {
t.Fatalf("Extract() error = %v, want provider context", err)
}
}
func TestConstructorSpecOptionsAndSafeMetadata(t *testing.T) {
if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") {
t.Fatalf("New(nil) error = %v", err)
}
if _, err := New(&fakeEnemyEventsLLMClient{}, Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one") {
t.Fatalf("New() error = %v", err)
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
t.Fatalf("DecodeOptions() error = %v", err)
}
first := ModuleSpec()
first.Requires[0] = "changed"
first.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed"
second := ModuleSpec()
if second.Requires[0] != "chunks" || second.ArtifactKind != dnd.EnemyEventListKind || second.ExecutionClass != contracts.ExecutionClassLLMBacked || second.ReferenceSlots[0].AcceptedMediaTypes[0] == "changed" {
t.Fatalf("ModuleSpec() reused mutable state: %#v", second)
}
registry := pipeline.NewExtractorRegistry()
if err := Register(registry); err != nil {
t.Fatal(err)
}
if spec, ok := registry.Spec(Key); !ok || spec.Key != Key || spec.ArtifactKind != dnd.EnemyEventListKind {
t.Fatalf("registered spec = %#v, %t", spec, ok)
}
extractor := newEnemyExtractor(t, &fakeEnemyEventsLLMClient{}, groundingReferences(t, "Ashfang", dnd.SceneKindCombat))
metadata := extractor.ManifestMetadata()
encoded, err := json.Marshal(metadata)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), "Ashfang") || metadata["mapping_policy"] != mappingPolicy || metadata["scene_gate_policy"] != sceneGatePolicy {
t.Fatalf("unsafe or incomplete metadata = %s", encoded)
}
fingerprints := extractor.CheckpointFingerprints()
if len(fingerprints) != 4 || fingerprints[0].Value != metadata["prompt_sha256"] || fingerprints[1].Value != metadata["response_schema_sha256"] || fingerprints[2].Value != mappingPolicy || fingerprints[3].Value != sceneGatePolicy {
t.Fatalf("fingerprints = %#v", fingerprints)
}
}
func newEnemyExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor {
t.Helper()
if len(references) == 0 {
references = []contracts.ReferenceSet{groundingReferences(t, "Ashfang", dnd.SceneKindCombat)}
}
extractor, err := New(client, Options{}, references...)
if err != nil {
t.Fatal(err)
}
return extractor
}
func mustGroundingResolver(t *testing.T, references contracts.ReferenceSet) *groundingResolver {
t.Helper()
resolver, err := newGroundingResolver(references)
if err != nil {
t.Fatal(err)
}
return resolver
}
func enemyExtractionRequest(t *testing.T) contracts.TypedExtractionRequest {
t.Helper()
chunk := combatChunk()
chunk.Units = []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}
chunk.MediaType = "application/json"
chunk.Content = []byte(`{"id":"combat-scene","units":[1,2,3,4]}`)
return contracts.TypedExtractionRequest{
Source: &source.SourceDocument{ID: "combat-session", Units: append([]source.SourceUnit(nil), chunk.Units...)},
Chunk: chunk,
SourceInput: contracts.NewLLMInputMaterial("source", "application/json", chunk.Content, digest(chunk.Content), "file:///combat-session.json"),
References: groundingReferences(t, "Ashfang", dnd.SceneKindCombat),
LLMProfile: "enemy-profile",
SessionID: "session-123",
}
}
type fakeEnemyEventsLLMClient struct {
response extractionResponse
err error
requests []contracts.StructuredCompletionRequest
}
func (client *fakeEnemyEventsLLMClient) CompleteStructured(_ context.Context, request contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
client.requests = append(client.requests, cloneEnemyRequest(request))
if client.err != nil {
return contracts.StructuredCompletionResponse{}, client.err
}
target, ok := out.(*extractionResponse)
if !ok {
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target")
}
*target = client.response
content, err := json.Marshal(client.response)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func cloneEnemyRequest(request contracts.StructuredCompletionRequest) contracts.StructuredCompletionRequest {
request.Inputs = request.Inputs.Clone()
return request
}

View File

@@ -0,0 +1,293 @@
// Package enemyevents prepares validated D&D combat grounding for enemy-event
// extraction.
package enemyevents
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"mime"
"sort"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
combatturncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
sceneregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/scenedescriptions/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const (
NPCRegistryReferenceSlot = npcregistry.ReferenceSlot
SceneDescriptionReferenceSlot = sceneregistry.ReferenceSlot
CombatTurnReferenceSlot = "combat_turns"
NPCInteractionReferenceSlot = "npc_interactions"
ReferenceMaxBytes = 1048576
promptProjectionMediaType = "application/json"
)
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
Glossary: "Optional campaign glossary reference material used only for disambiguation.",
Party: "Optional party roster reference material used only for disambiguation.",
Players: "Optional player list reference material used only for disambiguation.",
Roster: "Deprecated alias for party roster reference material used only for disambiguation.",
}
func referenceSlots() []contracts.ReferenceSlot {
slots := shared.ReferenceSlots(referenceSlotDescriptions)
slots = append(slots,
contracts.ReferenceSlot{
Name: NPCRegistryReferenceSlot,
Description: "Required normalized NPC registry used only for enemy-subject grounding, never as event evidence.",
Required: true,
AcceptedMediaTypes: []string{npccodec.MediaType},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
MaxBytes: ReferenceMaxBytes,
},
contracts.ReferenceSlot{
Name: SceneDescriptionReferenceSlot,
Description: "Required scene descriptions used only to determine exact combat eligibility, never as event evidence.",
Required: true,
AcceptedMediaTypes: []string{scenecodec.MediaType},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.SceneDescriptionListKind},
MaxBytes: ReferenceMaxBytes,
},
contracts.ReferenceSlot{
Name: CombatTurnReferenceSlot,
Description: "Required combat-turn artifact used only as source-free enemy-event grounding.",
Required: true,
AcceptedMediaTypes: []string{combatturncodec.MediaType},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.CombatTurnListKind},
MaxBytes: ReferenceMaxBytes,
},
contracts.ReferenceSlot{
Name: NPCInteractionReferenceSlot,
Description: "Required NPC-interaction artifact used only as source-free enemy-event grounding.",
Required: true,
AcceptedMediaTypes: []string{interactioncodec.MediaType},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCInteractionListKind},
MaxBytes: ReferenceMaxBytes,
},
)
sort.Slice(slots, func(left, right int) bool { return slots[left].Name < slots[right].Name })
return contracts.CloneReferenceSlots(slots)
}
// groundingResolver retains only validated, compact construction-time views.
// Per-operation generated references are decoded when supplied and never
// become static metadata.
type groundingResolver struct {
npcs *npcregistry.Resolver
scenes *sceneregistry.Resolver
combatTurns *contracts.LLMInputMaterial
npcInteractions *contracts.LLMInputMaterial
}
type grounding struct {
npcInput contracts.LLMInputMaterial
combatTurnInput contracts.LLMInputMaterial
npcInteractionInput contracts.LLMInputMaterial
}
func newGroundingResolver(references contracts.ReferenceSet) (*groundingResolver, error) {
npcs, err := npcregistry.NewResolver(references)
if err != nil {
return nil, fmt.Errorf("prepare NPC registry grounding: %w", err)
}
scenes, err := sceneregistry.NewResolver(references)
if err != nil {
return nil, fmt.Errorf("prepare scene eligibility: %w", err)
}
combatTurns, err := prepareCombatTurnInput(references)
if err != nil {
return nil, err
}
npcInteractions, err := prepareNPCInteractionInput(references)
if err != nil {
return nil, err
}
return &groundingResolver{
npcs: npcs,
scenes: scenes,
combatTurns: combatTurns,
npcInteractions: npcInteractions,
}, nil
}
func (r *groundingResolver) Resolve(references contracts.ReferenceSet) (grounding, error) {
if r == nil {
return grounding{}, fmt.Errorf("grounding resolver must not be nil")
}
npcs, err := r.npcs.Resolve(references)
if err != nil {
return grounding{}, fmt.Errorf("resolve NPC registry grounding: %w", err)
}
if !npcs.Bound() {
return grounding{}, fmt.Errorf("NPC registry reference is required")
}
combatTurns, err := resolveInput(references, CombatTurnReferenceSlot, r.combatTurns, prepareCombatTurnInput)
if err != nil {
return grounding{}, err
}
npcInteractions, err := resolveInput(references, NPCInteractionReferenceSlot, r.npcInteractions, prepareNPCInteractionInput)
if err != nil {
return grounding{}, err
}
return grounding{
npcInput: npcs.PromptInput(),
combatTurnInput: combatTurns,
npcInteractionInput: npcInteractions,
}, nil
}
func (r *groundingResolver) SceneMatch(references contracts.ReferenceSet, chunk *source.Chunk) (sceneregistry.ChunkMatch, error) {
if r == nil {
return sceneregistry.ChunkMatch{}, fmt.Errorf("grounding resolver must not be nil")
}
scenes, err := r.resolveScenes(references)
if err != nil {
return sceneregistry.ChunkMatch{}, err
}
return scenes.Match(chunk), nil
}
func (r *groundingResolver) resolveScenes(references contracts.ReferenceSet) (*sceneregistry.Registry, error) {
scenes, err := r.scenes.Resolve(references)
if err != nil {
return nil, fmt.Errorf("resolve scene eligibility: %w", err)
}
if !scenes.Bound() {
return nil, fmt.Errorf("scene descriptions reference is required")
}
return scenes, nil
}
func resolveInput(references contracts.ReferenceSet, slot string, seeded *contracts.LLMInputMaterial, prepare func(contracts.ReferenceSet) (*contracts.LLMInputMaterial, error)) (contracts.LLMInputMaterial, error) {
if _, ok := references.Slots[slot]; ok {
value, err := prepare(references)
if err != nil {
return contracts.LLMInputMaterial{}, err
}
if value == nil {
return contracts.LLMInputMaterial{}, fmt.Errorf("%s reference is required", slot)
}
return value.Clone(), nil
}
if seeded == nil {
return contracts.LLMInputMaterial{}, fmt.Errorf("%s reference is required", slot)
}
return seeded.Clone(), nil
}
func (g grounding) PromptInputs() contracts.LLMInputSet {
return contracts.LLMInputSet{
NPCRegistryReferenceSlot: g.npcInput.Clone(),
CombatTurnReferenceSlot: g.combatTurnInput.Clone(),
NPCInteractionReferenceSlot: g.npcInteractionInput.Clone(),
}
}
func prepareCombatTurnInput(references contracts.ReferenceSet) (*contracts.LLMInputMaterial, error) {
item, ok, err := referenceItem(references, CombatTurnReferenceSlot, combatturncodec.MediaType)
if err != nil || !ok {
return nil, err
}
value, err := combatturncodec.New().Decode(item.Content)
if err != nil {
return nil, fmt.Errorf("decode combat-turn grounding: invalid approved combat-turn JSON")
}
content, err := json.Marshal(struct {
CombatTurns []combatTurnProjection `json:"combat_turns"`
}{CombatTurns: projectCombatTurns(value.CombatTurns)})
if err != nil {
return nil, fmt.Errorf("encode combat-turn grounding: %w", err)
}
return newPromptInput(CombatTurnReferenceSlot, content), nil
}
func prepareNPCInteractionInput(references contracts.ReferenceSet) (*contracts.LLMInputMaterial, error) {
item, ok, err := referenceItem(references, NPCInteractionReferenceSlot, interactioncodec.MediaType)
if err != nil || !ok {
return nil, err
}
value, err := interactioncodec.New().Decode(item.Content)
if err != nil {
return nil, fmt.Errorf("decode NPC-interaction grounding: invalid approved NPC-interaction JSON")
}
content, err := json.Marshal(struct {
Interactions []npcInteractionProjection `json:"npc_interactions"`
}{Interactions: projectNPCInteractions(value.Interactions)})
if err != nil {
return nil, fmt.Errorf("encode NPC-interaction grounding: %w", err)
}
return newPromptInput(NPCInteractionReferenceSlot, content), nil
}
func referenceItem(references contracts.ReferenceSet, slotName, expectedMediaType string) (contracts.ReferenceItem, bool, error) {
slot, ok := references.Slots[slotName]
if !ok {
return contracts.ReferenceItem{}, false, nil
}
if len(slot.Items) == 0 {
return contracts.ReferenceItem{}, false, nil
}
if len(slot.Items) != 1 {
return contracts.ReferenceItem{}, false, fmt.Errorf("reference slot %q must contain exactly one item", slotName)
}
item := slot.Items[0]
mediaType, _, err := mime.ParseMediaType(item.MediaType)
if err != nil {
return contracts.ReferenceItem{}, false, fmt.Errorf("reference slot %q item media type is invalid", slotName)
}
if !strings.EqualFold(mediaType, expectedMediaType) {
return contracts.ReferenceItem{}, false, fmt.Errorf("reference slot %q item media type must be %s", slotName, expectedMediaType)
}
if len(item.Content) > ReferenceMaxBytes {
return contracts.ReferenceItem{}, false, fmt.Errorf("reference slot %q item is %d bytes, limit %d", slotName, len(item.Content), ReferenceMaxBytes)
}
return item, true, nil
}
type combatTurnProjection struct {
Actor string `json:"actor"`
TurnKind dnd.CombatTurnKind `json:"turn_kind"`
}
func projectCombatTurns(turns []dnd.CombatTurn) []combatTurnProjection {
if turns == nil {
return nil
}
projection := make([]combatTurnProjection, len(turns))
for index, turn := range turns {
projection[index] = combatTurnProjection{Actor: turn.Actor, TurnKind: turn.TurnKind}
}
return projection
}
type npcInteractionProjection struct {
Name string `json:"name"`
Kind dnd.NPCInteractionKind `json:"kind"`
}
func projectNPCInteractions(interactions []dnd.NPCInteraction) []npcInteractionProjection {
projection := make([]npcInteractionProjection, 0, len(interactions))
for _, interaction := range interactions {
if interaction.Kind == dnd.NPCInteractionKindCombatOpponent {
projection = append(projection, npcInteractionProjection{Name: interaction.Name, Kind: interaction.Kind})
}
}
return projection
}
func newPromptInput(name string, content []byte) *contracts.LLMInputMaterial {
sum := sha256.Sum256(content)
material := contracts.NewLLMInputMaterial(name, promptProjectionMediaType, content, "sha256:"+hex.EncodeToString(sum[:]), "")
return &material
}

View File

@@ -0,0 +1,279 @@
package enemyevents
import (
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
combatturncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
sceneregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/scenedescriptions/registry"
)
func TestReferenceSlotsDescribeRequiredTypedArtifacts(t *testing.T) {
slots := referenceSlots()
if len(slots) != 8 {
t.Fatalf("ReferenceSlots() count = %d, want 8", len(slots))
}
byName := make(map[string]contracts.ReferenceSlot, len(slots))
for index, slot := range slots {
if index > 0 && slots[index-1].Name > slot.Name {
t.Fatalf("ReferenceSlots() is not sorted: %#v", slots)
}
byName[slot.Name] = slot
}
for _, want := range []struct {
name string
kind contracts.ArtifactKind
}{
{NPCRegistryReferenceSlot, dnd.NPCListKind},
{SceneDescriptionReferenceSlot, dnd.SceneDescriptionListKind},
{CombatTurnReferenceSlot, dnd.CombatTurnListKind},
{NPCInteractionReferenceSlot, dnd.NPCInteractionListKind},
} {
slot, ok := byName[want.name]
if !ok || !slot.Required || slot.MaxBytes != ReferenceMaxBytes || len(slot.AcceptedMediaTypes) != 1 || slot.AcceptedMediaTypes[0] != "application/json" || len(slot.AcceptedArtifactKinds) != 1 || slot.AcceptedArtifactKinds[0] != want.kind {
t.Fatalf("slot %q = %#v", want.name, slot)
}
}
slots[0].AcceptedMediaTypes[0] = "changed"
if referenceSlots()[0].AcceptedMediaTypes[0] == "changed" {
t.Fatal("ReferenceSlots() returned caller-owned storage")
}
}
func TestGroundingProducesExactSourceFreePromptInputs(t *testing.T) {
resolver, err := newGroundingResolver(groundingReferences(t, "Ashfang", dnd.SceneKindCombat))
if err != nil {
t.Fatal(err)
}
resolved, err := resolver.Resolve(contracts.ReferenceSet{})
if err != nil {
t.Fatal(err)
}
inputs := resolved.PromptInputs()
want := map[string]string{
NPCRegistryReferenceSlot: `{"npcs":[{"name":"Ashfang"}]}`,
CombatTurnReferenceSlot: `{"combat_turns":[{"actor":"Ashfang","turn_kind":"turn"},{"actor":"Aria","turn_kind":"reaction"}]}`,
NPCInteractionReferenceSlot: `{"npc_interactions":[{"name":"Ashfang","kind":"combat_opponent"}]}`,
}
if len(inputs) != len(want) {
t.Fatalf("PromptInputs() = %#v", inputs)
}
for name, content := range want {
input, ok := inputs[name]
if !ok || string(input.Content) != content || input.Digest != digest([]byte(content)) || input.OriginURI != "" || input.MediaType != "application/json" {
t.Fatalf("PromptInputs()[%q] = %#v, want %q", name, input, content)
}
for _, forbidden := range []string{"source_ref", "npc-ashfang", "origin", "summary", "combat-session"} {
if strings.Contains(string(input.Content), forbidden) {
t.Fatalf("PromptInputs()[%q] leaked %q: %s", name, forbidden, input.Content)
}
}
}
if _, ok := inputs[SceneDescriptionReferenceSlot]; ok {
t.Fatal("scene descriptions were rendered as prompt grounding")
}
inputs[CombatTurnReferenceSlot] = contracts.NewLLMInputMaterial("changed", "text/plain", []byte("changed"), "", "")
if next := resolved.PromptInputs()[CombatTurnReferenceSlot]; string(next.Content) != want[CombatTurnReferenceSlot] {
t.Fatal("PromptInputs() did not return a defensive copy")
}
}
func TestGroundingResolvesGeneratedReferencesAndSceneEligibility(t *testing.T) {
prepared := groundingReferences(t, "Ashfang", dnd.SceneKindCombat)
resolver, err := newGroundingResolver(prepared)
if err != nil {
t.Fatal(err)
}
generated := groundingReferences(t, "Grimjaw", dnd.SceneKindNarrative)
resolved, err := resolver.Resolve(generated)
if err != nil {
t.Fatal(err)
}
if got := string(resolved.PromptInputs()[NPCRegistryReferenceSlot].Content); got != `{"npcs":[{"name":"Grimjaw"}]}` {
t.Fatalf("generated NPC projection = %s", got)
}
match, err := resolver.SceneMatch(generated, combatChunk())
if err != nil {
t.Fatal(err)
}
if match != (sceneregistry.ChunkMatch{State: sceneregistry.MatchExact, Kind: dnd.SceneKindNarrative}) {
t.Fatalf("generated scene match = %#v", match)
}
match, err = resolver.SceneMatch(generated, &source.Chunk{ID: "other", Ref: combatChunk().Ref})
if err != nil {
t.Fatal(err)
}
if match.State != sceneregistry.MatchMissing {
t.Fatal("missing scene was not reported")
}
mismatched := combatChunk()
mismatched.Ref.EndUnitID++
match, err = resolver.SceneMatch(generated, mismatched)
if err != nil {
t.Fatal(err)
}
if match.State != sceneregistry.MatchMismatched {
t.Fatal("mismatched scene was not reported")
}
static, err := resolver.Resolve(contracts.ReferenceSet{})
if err != nil {
t.Fatal(err)
}
if got := string(static.PromptInputs()[NPCRegistryReferenceSlot].Content); got != `{"npcs":[{"name":"Ashfang"}]}` {
t.Fatalf("static NPC projection changed after generated resolution: %s", got)
}
match, err = resolver.SceneMatch(contracts.ReferenceSet{}, combatChunk())
if err != nil {
t.Fatal(err)
}
if match != (sceneregistry.ChunkMatch{State: sceneregistry.MatchExact, Kind: dnd.SceneKindCombat}) {
t.Fatalf("static scene match = %#v", match)
}
}
func TestGroundingRejectsMissingAndInvalidReferences(t *testing.T) {
valid := groundingReferences(t, "Ashfang", dnd.SceneKindCombat)
for _, test := range []struct {
slot string
want string
}{
{NPCRegistryReferenceSlot, "NPC registry"},
{SceneDescriptionReferenceSlot, "scene descriptions"},
{CombatTurnReferenceSlot, CombatTurnReferenceSlot},
{NPCInteractionReferenceSlot, NPCInteractionReferenceSlot},
} {
t.Run("missing "+test.slot, func(t *testing.T) {
resolver, err := newGroundingResolver(withoutSlot(valid, test.slot))
if err != nil {
t.Fatal(err)
}
if test.slot == SceneDescriptionReferenceSlot {
_, err = resolver.SceneMatch(contracts.ReferenceSet{}, combatChunk())
} else {
_, err = resolver.Resolve(contracts.ReferenceSet{})
}
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("grounding error = %v, want missing %q reference", err, test.slot)
}
})
}
tests := []struct {
name string
set contracts.ReferenceSet
}{
{"multiple items", replaceSlot(valid, CombatTurnReferenceSlot, contracts.ResolvedReferenceSlot{Items: []contracts.ReferenceItem{{MediaType: "application/json"}, {MediaType: "application/json"}}})},
{"malformed durable content", replaceItem(valid, NPCInteractionReferenceSlot, contracts.ReferenceItem{MediaType: "application/json", Content: []byte(`{}`)})},
{"wrong media type", replaceItem(valid, CombatTurnReferenceSlot, contracts.ReferenceItem{MediaType: "text/plain", Content: valid.Slots[CombatTurnReferenceSlot].Items[0].Content})},
{"oversize", replaceItem(valid, CombatTurnReferenceSlot, contracts.ReferenceItem{MediaType: "application/json", Content: make([]byte, ReferenceMaxBytes+1)})},
}
resolver, err := newGroundingResolver(valid)
if err != nil {
t.Fatal(err)
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := resolver.Resolve(test.set); err == nil {
t.Fatal("Resolve() error = nil")
}
})
}
}
func groundingReferences(t *testing.T, enemy string, sceneKind dnd.SceneKind) contracts.ReferenceSet {
t.Helper()
npcContent, err := npccodec.New().Encode(dnd.NPCList{NPCs: []dnd.NPC{{
ID: identity.DeriveID(enemy),
Name: enemy,
SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}},
}}})
if err != nil {
t.Fatal(err)
}
sceneContent, err := scenecodec.New().Encode(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{
ID: "combat-scene",
SourceRef: source.SourceRef{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 4},
Kind: sceneKind,
Title: "Combat session",
Summary: "A detailed scene summary that must not reach the prompt.",
}}})
if err != nil {
t.Fatal(err)
}
turnContent, err := combatturncodec.New().Encode(dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
{Actor: enemy, TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}},
{Actor: "Aria", TurnKind: dnd.CombatTurnKindReaction, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 2}}},
}})
if err != nil {
t.Fatal(err)
}
interactionContent, err := interactioncodec.New().Encode(dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
{Name: enemy, Kind: dnd.NPCInteractionKindCombatOpponent, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}},
{Name: "Aria", Kind: dnd.NPCInteractionKindCombatAlly, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 2}}},
}})
if err != nil {
t.Fatal(err)
}
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
NPCRegistryReferenceSlot: {Items: []contracts.ReferenceItem{newReferenceItem(NPCRegistryReferenceSlot, npcContent)}},
SceneDescriptionReferenceSlot: {Items: []contracts.ReferenceItem{newReferenceItem(SceneDescriptionReferenceSlot, sceneContent)}},
CombatTurnReferenceSlot: {Items: []contracts.ReferenceItem{newReferenceItem(CombatTurnReferenceSlot, turnContent)}},
NPCInteractionReferenceSlot: {Items: []contracts.ReferenceItem{newReferenceItem(NPCInteractionReferenceSlot, interactionContent)}},
}}
}
func newReferenceItem(slot string, content []byte) contracts.ReferenceItem {
return contracts.ReferenceItem{
SlotName: slot,
MediaType: "application/json",
Content: append([]byte(nil), content...),
Digest: digest(content),
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///private/reference.json"},
Producer: contracts.ReferenceProducer{PipelineID: "prior", StepID: "extract", LaneID: "lane", ModuleKey: "dnd/example"},
}
}
func withoutSlot(set contracts.ReferenceSet, name string) contracts.ReferenceSet {
cloned := contracts.ReferenceSet{Slots: make(map[string]contracts.ResolvedReferenceSlot, len(set.Slots)-1)}
for slotName, slot := range set.Slots {
if slotName != name {
cloned.Slots[slotName] = slot
}
}
return cloned
}
func replaceSlot(set contracts.ReferenceSet, name string, value contracts.ResolvedReferenceSlot) contracts.ReferenceSet {
cloned := contracts.ReferenceSet{Slots: make(map[string]contracts.ResolvedReferenceSlot, len(set.Slots))}
for slotName, slot := range set.Slots {
cloned.Slots[slotName] = slot
}
cloned.Slots[name] = value
return cloned
}
func replaceItem(set contracts.ReferenceSet, name string, item contracts.ReferenceItem) contracts.ReferenceSet {
return replaceSlot(set, name, contracts.ResolvedReferenceSlot{Items: []contracts.ReferenceItem{item}})
}
func combatChunk() *source.Chunk {
return &source.Chunk{ID: "combat-scene", Ref: source.SourceRef{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 4}}
}
func digest(content []byte) string {
sum := sha256.Sum256(content)
return "sha256:" + hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,60 @@
package enemyevents
import (
"sort"
"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"
)
type orderedEnemyEvent struct {
value dnd.EnemyEvent
earliest int
hasEvidence bool
}
func canonicalEnemyEventList(response extractionResponse, order shared.SourceRefOrder, sourceID string) dnd.EnemyEventList {
if response.Events == nil {
return dnd.EnemyEventList{}
}
ordered := make([]orderedEnemyEvent, len(response.Events))
for index, event := range response.Events {
refs := order.Canonicalize(sourceRefs(event.SourceRefs, sourceID))
earliest, hasEvidence := order.EarliestValid(refs)
ordered[index] = orderedEnemyEvent{
value: dnd.EnemyEvent{
Name: event.Name,
Kind: dnd.EnemyEventKind(event.Kind),
SourceRefs: refs,
},
earliest: earliest,
hasEvidence: hasEvidence,
}
}
sort.SliceStable(ordered, func(left, right int) bool {
if ordered[left].hasEvidence != ordered[right].hasEvidence {
return ordered[left].hasEvidence
}
if !ordered[left].hasEvidence {
return false
}
return ordered[left].earliest < ordered[right].earliest
})
events := make([]dnd.EnemyEvent, len(ordered))
for index := range ordered {
events[index] = ordered[index].value
}
return dnd.EnemyEventList{Events: events}
}
func sourceRefs(refs []enemySourceRefResponse, sourceID string) []source.SourceRef {
if refs == nil {
return nil
}
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
}

View File

@@ -0,0 +1,16 @@
package enemyevents
type extractionResponse struct {
Events []enemyEventResponse `json:"events"`
}
type enemyEventResponse struct {
Name string `json:"name"`
Kind string `json:"kind"`
SourceRefs []enemySourceRefResponse `json:"source_refs"`
}
type enemySourceRefResponse struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
}

View File

@@ -0,0 +1,54 @@
package enemyevents
import (
"fmt"
"sync"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const promptAssetRoot = "assets/prompts"
var promptAssetManifest = shared.PromptAssetManifest{
ModuleDir: "dnd.enemy_events",
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "dnd.enemy_events.yaml", Path: "assets/prompts/dnd.enemy_events.yaml"},
{Name: "grounding.md", Path: "assets/prompts/grounding.md"},
{Name: "task.md", Path: "assets/prompts/task.md"},
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
},
SharedFiles: []string{
"common-dnd-system.md",
"common-dnd-extraction-evidence.md",
"common-dnd-identity.md",
"common-dnd-transcript.md",
"common-dnd-references.md",
"common-dnd-npcs.md",
},
}
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
promptFS, err := promptAssetManifest.PromptFS(embeddedAssets)
if err != nil {
return fmt.Errorf("prepare enemy-event prompt assets: %w", err)
}
if err := registry.RegisterPromptFS(promptFS, promptAssetRoot); err != nil {
return err
}
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas")
}
func promptAssetMetadata() (string, error) {
promptAssetHashOnce.Do(func() {
promptAssetHash, promptAssetHashErr = promptAssetManifest.Hash(embeddedAssets)
})
return promptAssetHash, promptAssetHashErr
}
var (
promptAssetHashOnce sync.Once
promptAssetHash string
promptAssetHashErr error
)

View File

@@ -0,0 +1,123 @@
package enemyevents
import (
"context"
"io/fs"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/promptkit"
)
func TestRegisterPromptAssetsAndPrepareEnemyEventPrompt(t *testing.T) {
registry := llm.NewAssetRegistry()
if err := RegisterPromptAssets(registry); err != nil {
t.Fatal(err)
}
schemaFS, err := registry.SchemaFS()
if err != nil {
t.Fatal(err)
}
if _, err := fs.ReadFile(schemaFS, "dnd_enemy_events_llm.v1.json"); err != nil {
t.Fatalf("response schema asset: %v", err)
}
prepared := prepareEnemyEventPrompt(t)
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_enemy_events_llm.v1.json" || prepared.SelectedProfileID != "dnd-extraction" {
t.Fatalf("prepared prompt = %#v", prepared)
}
transcriptIndex := renderedMessageIndex(t, prepared.Messages, "enemy-transcript")
for _, sentinel := range []string{"enemy-npc", "enemy-turn", "enemy-opponent", "Extract Dungeons & Dragons enemy events"} {
if index := renderedMessageIndex(t, prepared.Messages, sentinel); index <= transcriptIndex {
t.Fatalf("message containing %q has index %d, want after transcript index %d", sentinel, index, transcriptIndex)
}
}
instructionIndex := renderedMessageIndex(t, prepared.Messages, "Return the `events` array")
if instructionIndex <= transcriptIndex {
t.Fatalf("instruction index = %d, want after transcript index %d", instructionIndex, transcriptIndex)
}
if instructionIndex != len(prepared.Messages)-1 {
t.Fatalf("instruction message index = %d, want final message", instructionIndex)
}
if cache := prepared.Messages[instructionIndex].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral {
t.Fatalf("final instruction cache control = %#v", cache)
}
}
func TestEnemyEventPromptRequiresGroundingInputs(t *testing.T) {
engine := newEnemyEventPromptEngine(t)
for _, inputName := range []string{"npcs", "combat_turns", "npc_interactions"} {
t.Run(inputName, func(t *testing.T) {
inputs := enemyEventPromptInputs()
delete(inputs, inputName)
_, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: PromptID, PromptVersion: SchemaVersion, Inputs: inputs,
})
if err == nil || !strings.Contains(err.Error(), inputName) {
t.Fatalf("Prepare() error = %v, want required %q input", err, inputName)
}
})
}
}
func prepareEnemyEventPrompt(t *testing.T) *promptkit.PreparedRun {
t.Helper()
prepared, err := newEnemyEventPromptEngine(t).Prepare(context.Background(), promptkit.RunRequest{
PromptID: PromptID, PromptVersion: SchemaVersion, Inputs: enemyEventPromptInputs(),
})
if err != nil {
t.Fatal(err)
}
return prepared
}
func newEnemyEventPromptEngine(t *testing.T) *promptkit.Engine {
t.Helper()
registry := llm.NewAssetRegistry()
if err := RegisterPromptAssets(registry); err != nil {
t.Fatal(err)
}
options, err := registry.PromptKitOptions()
if err != nil {
t.Fatal(err)
}
options = append(options, promptkit.WithProfiles(promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
ID: "dnd-extraction", Endpoint: "http://127.0.0.1:1/v1", Model: "enemy-test-model",
})))
engine, err := promptkit.NewEngine(promptkit.Config{Timeout: time.Second}, options...)
if err != nil {
t.Fatal(err)
}
return engine
}
func enemyEventPromptInputs() map[string]promptkit.ArtifactRef {
return map[string]promptkit.ArtifactRef{
"transcript": promptkit.Inline(`{"value":"enemy-transcript"}`),
"players": promptkit.Inline("enemy-player"),
"party": promptkit.Inline("enemy-party"),
"glossary": promptkit.Inline("enemy-glossary"),
"npcs": promptkit.Inline(`{"npcs":[{"name":"enemy-npc"}]}`),
"combat_turns": promptkit.Inline(`{"combat_turns":[{"actor":"enemy-turn","turn_kind":"turn"}]}`),
"npc_interactions": promptkit.Inline(`{"npc_interactions":[{"name":"enemy-opponent","kind":"combat_opponent"}]}`),
}
}
func renderedMessageIndex(t *testing.T, messages []promptkit.RenderedMessage, sentinel string) int {
t.Helper()
index := -1
occurrences := 0
for messageIndex, message := range messages {
count := strings.Count(message.Content, sentinel)
if count > 0 {
index = messageIndex
occurrences += count
}
}
if occurrences != 1 {
t.Fatalf("message sentinel %q rendered %d times, want exactly once", sentinel, occurrences)
}
return index
}

View File

@@ -0,0 +1,21 @@
package enemyevents
import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
const (
PromptID = "dnd.enemy_events"
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_enemy_events_llm")
ResponseSchemaID = "notarius.dnd.enemy_events.llm"
ResponseSchemaName = "notarius_dnd_enemy_events_llm_v1"
SchemaVersion = "v1"
)
func loadResponseSchema() (llm.ResponseSchema, error) {
return llm.LoadResponseSchema(embeddedAssets, llm.ResponseSchemaDefinition{
Key: ResponseSchemaKey,
ID: ResponseSchemaID,
Version: SchemaVersion,
Name: ResponseSchemaName,
AssetPath: "assets/schemas/dnd_enemy_events_llm.v1.json",
})
}

View File

@@ -0,0 +1,97 @@
package enemyevents
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/santhosh-tekuri/jsonschema/v6"
)
func TestResponseSchemaDefinesPrivateStructuralBoundary(t *testing.T) {
schema, err := loadResponseSchema()
if err != nil {
t.Fatal(err)
}
if schema.Key != ResponseSchemaKey || schema.ID != ResponseSchemaID || schema.Name != ResponseSchemaName || schema.Version != SchemaVersion || !strings.HasPrefix(schema.SHA256, "sha256:") || !json.Valid(schema.JSONSchema) {
t.Fatalf("schema = %#v", schema)
}
valid := validEnemyResponse()
content, err := json.Marshal(valid)
if err != nil {
t.Fatal(err)
}
if err := validateEnemySchema(content, schema.JSONSchema); err != nil {
t.Fatalf("valid response rejected: %v", err)
}
semantic := validEnemyResponse()
event := semantic["events"].([]any)[0].(map[string]any)
event["name"] = ""
event["kind"] = "unsupported"
ref := event["source_refs"].([]any)[0].(map[string]any)
ref["start_unit_id"] = 0
ref["end_unit_id"] = -1
content, err = json.Marshal(semantic)
if err != nil || validateEnemySchema(content, schema.JSONSchema) != nil {
t.Fatalf("validator-owned semantics were rejected: %v", err)
}
}
func TestResponseSchemaRejectsInvalidStructure(t *testing.T) {
schema, err := loadResponseSchema()
if err != nil {
t.Fatal(err)
}
for _, mutate := range []func(map[string]any){
func(event map[string]any) { delete(event, "name") },
func(event map[string]any) { event["kind"] = 1 },
func(event map[string]any) { event["unexpected"] = true },
func(event map[string]any) { event["source_refs"].([]any)[0].(map[string]any)["source_id"] = "session" },
} {
candidate := validEnemyResponse()
mutate(candidate["events"].([]any)[0].(map[string]any))
content, err := json.Marshal(candidate)
if err != nil {
t.Fatal(err)
}
if err := validateEnemySchema(content, schema.JSONSchema); err == nil {
t.Fatal("private schema accepted structurally invalid response")
}
}
first, err := loadResponseSchema()
if err != nil {
t.Fatal(err)
}
first.JSONSchema[0] = '['
second, err := loadResponseSchema()
if err != nil || !json.Valid(second.JSONSchema) || bytes.Equal(first.JSONSchema, second.JSONSchema) {
t.Fatalf("schema defensive copy = %s, %v", second.JSONSchema, err)
}
}
func validEnemyResponse() map[string]any {
return map[string]any{"events": []any{map[string]any{
"name": "Ashfang", "kind": "engaged", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}},
}}}
}
func validateEnemySchema(instanceContent, schemaContent []byte) error {
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(instanceContent))
if err != nil {
return err
}
document, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaContent))
if err != nil {
return err
}
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("schema.json", document); err != nil {
return err
}
schema, err := compiler.Compile("schema.json")
if err != nil {
return err
}
return schema.Validate(instance)
}

View File

@@ -0,0 +1,297 @@
// Package enemyevents normalizes merged D&D enemy-event candidates.
package enemyevents
import (
"context"
"fmt"
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents"
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/enemy-events"
normalizationPolicy = "dnd.enemy_events.normalize.v1"
NormalizationPolicy = normalizationPolicy
ReasonCodeNameCanonicalized = "enemy_event_name_canonicalized"
ReasonCodeSourceRefsNormalized = "source_references_normalized"
ReasonCodeEventsReordered = "enemy_events_reordered"
ReasonCodeDuplicateCollapsed = "duplicate_enemy_event_collapsed"
ReasonCodeWarningsOmitted = "enemy_event_normalization_warnings_omitted"
)
const (
NPCRegistryReferenceSlot = npcregistry.ReferenceSlot
NPCRegistryMaxBytes = npcregistry.MaxBytes
)
var requiredCapabilities = []string{"merged"}
var providedCapabilities = []string{"normalized"}
var _ contracts.Normalizer[dnd.EnemyEventList] = (*Normalizer)(nil)
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
type Options struct{}
type Normalizer struct {
npcResolver *npcregistry.Resolver
}
func New(_ Options, references ...contracts.ReferenceSet) (*Normalizer, error) {
if len(references) > 1 {
return nil, normalizerErrorf("at most one reference set may be supplied")
}
var referenceSet contracts.ReferenceSet
if len(references) == 1 {
referenceSet = references[0]
}
resolver, err := npcregistry.NewResolver(referenceSet)
if err != nil {
return nil, normalizerErrorf("prepare NPC registry: %w", err)
}
return &Normalizer{npcResolver: resolver}, nil
}
func (n *Normalizer) Key() string { return Key }
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() }
func (n *Normalizer) ManifestMetadata() map[string]any {
if n == nil || n.npcResolver == nil {
return nil
}
metadata := map[string]any{"normalization_policy": normalizationPolicy}
if seeded := n.npcResolver.Seeded(); seeded.Bound() {
metadata["npc_registry_digest"] = seeded.Digest()
}
return metadata
}
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if n == nil || n.npcResolver == nil {
return nil
}
return []pipeline.CheckpointFingerprint{
{Name: "normalization_policy", Value: normalizationPolicy},
{Name: "npc_registry", Value: n.npcResolver.Seeded().ProjectionDigest()},
}
}
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.EnemyEventList]) (contracts.TypedNormalizeResult[dnd.EnemyEventList], error) {
if n == nil || n.npcResolver == nil {
return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("normalizer must not be nil")
}
if ctx == nil {
return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("context must not be nil")
}
if err := ctx.Err(); err != nil {
return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("context error before normalize: %w", err)
}
registry, err := n.npcResolver.Resolve(req.References)
if err != nil {
return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("resolve NPC registry: %w", err)
}
if !registry.Bound() {
return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("NPC registry reference is required")
}
index := source.NewDocumentIndex(req.Source)
order := shared.NewSourceRefOrderFromIndex(index)
value, warnings := normalizeList(req.MergeOutput.Value, order, registry)
return contracts.TypedNormalizeResult[dnd.EnemyEventList]{Value: value, Warnings: warnings}, nil
}
type normalizedRecord struct {
event dnd.EnemyEvent
inputIndex int
}
type nameCanonicalization struct {
from string
to string
}
func normalizeList(input dnd.EnemyEventList, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.EnemyEventList, []contracts.Warning) {
if input.Events == nil {
return dnd.EnemyEventList{}, nil
}
records := make([]normalizedRecord, len(input.Events))
warnings := make([]contracts.Warning, 0)
for index, inputEvent := range input.Events {
event, nameChange, refsChanged := normalizeEvent(inputEvent, order, registry)
records[index] = normalizedRecord{event: event, inputIndex: index}
if nameChange != nil {
warnings = append(warnings, contracts.Warning{
Scope: eventScope(index),
ReasonCode: ReasonCodeNameCanonicalized,
Message: fmt.Sprintf("input index %d: subject canonicalized from %s to %s",
index, diagnostics.Quote(nameChange.from), diagnostics.Quote(nameChange.to)),
})
}
if refsChanged {
warnings = append(warnings, contracts.Warning{
Scope: eventScope(index),
ReasonCode: ReasonCodeSourceRefsNormalized,
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
index, len(inputEvent.SourceRefs), len(event.SourceRefs)),
})
}
}
sort.SliceStable(records, func(left, right int) bool {
return enemyeventmodel.Less(order, records[left].event, records[right].event)
})
for position, record := range records {
if position == record.inputIndex {
continue
}
warnings = append(warnings, contracts.Warning{
Scope: eventScope(record.inputIndex),
ReasonCode: ReasonCodeEventsReordered,
Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position),
})
}
output, duplicateWarnings := collapseDuplicates(records, order)
warnings = append(warnings, duplicateWarnings...)
return dnd.EnemyEventList{Events: output}, diagnostics.LimitWarnings(warnings, "enemy_events", ReasonCodeWarningsOmitted)
}
func normalizeEvent(input dnd.EnemyEvent, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.EnemyEvent, *nameCanonicalization, bool) {
output := cloneEvent(input)
output.Name = enemyeventmodel.NormalizeDisplay(input.Name)
if canonical, ok := registry.Lookup(output.Name); ok {
output.Name = enemyeventmodel.NormalizeDisplay(canonical.Name)
}
var nameChange *nameCanonicalization
if input.Name != output.Name {
nameChange = &nameCanonicalization{from: input.Name, to: output.Name}
}
output.SourceRefs = order.Canonicalize(input.SourceRefs)
return output, nameChange, !rawSourceRefsEqual(input.SourceRefs, output.SourceRefs)
}
func cloneEvent(input dnd.EnemyEvent) dnd.EnemyEvent {
output := input
if input.SourceRefs != nil {
output.SourceRefs = append([]source.SourceRef(nil), input.SourceRefs...)
}
return output
}
func rawSourceRefsEqual(left, right []source.SourceRef) bool {
if (left == nil) != (right == nil) || len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
type duplicateGroup struct {
retainedIndex int
removed []int
}
func collapseDuplicates(records []normalizedRecord, order shared.SourceRefOrder) ([]dnd.EnemyEvent, []contracts.Warning) {
if len(records) == 0 {
return make([]dnd.EnemyEvent, 0), nil
}
kept := make([]normalizedRecord, 0, len(records))
groups := make([]duplicateGroup, 0)
for _, record := range records {
match := -1
for index := range kept {
if enemyeventmodel.ExactEqual(order, kept[index].event, record.event) {
match = index
break
}
}
if match < 0 {
kept = append(kept, record)
groups = append(groups, duplicateGroup{retainedIndex: record.inputIndex})
continue
}
groups[match].removed = append(groups[match].removed, record.inputIndex)
}
output := make([]dnd.EnemyEvent, len(kept))
for index, record := range kept {
output[index] = cloneEvent(record.event)
}
warnings := make([]contracts.Warning, 0)
for _, group := range groups {
if len(group.removed) == 0 {
continue
}
issues := make([]string, len(group.removed))
for index, removed := range group.removed {
issues[index] = fmt.Sprintf("removed input index %d", removed)
}
warnings = append(warnings, contracts.Warning{
Scope: eventScope(group.retainedIndex),
ReasonCode: ReasonCodeDuplicateCollapsed,
Message: diagnostics.Aggregate(
fmt.Sprintf("duplicate enemy event collapsed; retained input index %d", group.retainedIndex), issues),
})
}
return output, warnings
}
func eventScope(index int) string { return fmt.Sprintf("events[%d]", index) }
func referenceSlots() []contracts.ReferenceSlot {
return contracts.CloneReferenceSlots([]contracts.ReferenceSlot{{
Name: NPCRegistryReferenceSlot,
Description: "Required normalized NPC registry used only to canonicalize enemy-subject names, never as event evidence.",
Required: true,
AcceptedMediaTypes: []string{"application/json"},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
MaxBytes: NPCRegistryMaxBytes,
}})
}
func ModuleSpec() pipeline.ModuleSpec {
return pipeline.ModuleSpec{
Key: Key,
Stage: pipeline.StageNormalize,
ExecutionClass: contracts.ExecutionClassDeterministic,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.EnemyEventListKind,
ReferenceSlots: referenceSlots(),
}
}
func Register(registry *pipeline.NormalizerRegistry) error {
return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.EnemyEventList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(options, request.References)
})
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, normalizerErrorf("%w", err)
}
return Options{}, nil
}
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
func normalizerErrorf(format string, args ...any) error {
return fmt.Errorf("dnd enemy events normalizer: "+format, args...)
}

View File

@@ -0,0 +1,207 @@
package enemyevents
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
)
func TestNormalizeCanonicalizesSubjectsEvidenceOrderAndDuplicates(t *testing.T) {
document := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}, {ID: 20}, {ID: 40}}}
ref := func(unitID int) []source.SourceRef {
return []source.SourceRef{{SourceID: document.ID, StartUnitID: unitID, EndUnitID: unitID}}
}
input := dnd.EnemyEventList{Events: []dnd.EnemyEvent{
{Name: " áRIA ", Kind: dnd.EnemyEventKindKilled, SourceRefs: append(ref(30), ref(10)...)},
{Name: " Remaining Orcs ", Kind: dnd.EnemyEventKindEngaged, SourceRefs: ref(20)},
{Name: "Ária", Kind: dnd.EnemyEventKindKilled, SourceRefs: []source.SourceRef{{SourceID: document.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: document.ID, StartUnitID: 30, EndUnitID: 30}}},
{Name: "Ária", Kind: dnd.EnemyEventKindFled, SourceRefs: ref(40)},
}}
originalRefs := append([]source.SourceRef(nil), input.Events[0].SourceRefs...)
normalizer := newNormalizer(t, npcReferences(t))
result, err := normalizer.Normalize(context.Background(), normalizeRequest(document, input, contracts.ReferenceSet{}))
if err != nil {
t.Fatal(err)
}
got := result.Value.Events
if len(got) != 3 || got[0].Name != "Ária" || got[0].Kind != dnd.EnemyEventKindKilled || !reflect.DeepEqual(got[0].SourceRefs, []source.SourceRef{{SourceID: document.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: document.ID, StartUnitID: 10, EndUnitID: 10}}) || got[1].Name != "Remaining Orcs" || got[2].Kind != dnd.EnemyEventKindFled {
t.Fatalf("normalized events = %#v", got)
}
for _, reason := range []string{ReasonCodeNameCanonicalized, ReasonCodeSourceRefsNormalized, ReasonCodeEventsReordered, ReasonCodeDuplicateCollapsed} {
if !hasWarning(result.Warnings, reason) {
t.Fatalf("warnings = %#v, missing %q", result.Warnings, reason)
}
}
if !reflect.DeepEqual(input.Events[0].SourceRefs, originalRefs) {
t.Fatalf("Normalize() mutated input: %#v", input)
}
got[0].SourceRefs[0].StartUnitID = 999
if input.Events[0].SourceRefs[0].StartUnitID == 999 {
t.Fatal("normalized source references alias input")
}
}
func TestNormalizeUsesExplicitKindOrderAndPreservesDistinctObservations(t *testing.T) {
document := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}}}
ref := func(unitID int) []source.SourceRef {
return []source.SourceRef{{SourceID: document.ID, StartUnitID: unitID, EndUnitID: unitID}}
}
input := dnd.EnemyEventList{Events: []dnd.EnemyEvent{
{Name: "Ashfang", Kind: dnd.EnemyEventKindKilled, SourceRefs: ref(1)},
{Name: "Ashfang", Kind: dnd.EnemyEventKindFled, SourceRefs: ref(1)},
{Name: "Ashfang", Kind: dnd.EnemyEventKindCaptured, SourceRefs: ref(1)},
{Name: "Ashfang", Kind: dnd.EnemyEventKindIncapacitated, SourceRefs: ref(1)},
{Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: ref(1)},
{Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: ref(3)},
}}
result, err := newNormalizer(t, npcReferences(t)).Normalize(context.Background(), normalizeRequest(document, input, contracts.ReferenceSet{}))
if err != nil {
t.Fatal(err)
}
want := []dnd.EnemyEventKind{
dnd.EnemyEventKindEngaged,
dnd.EnemyEventKindIncapacitated,
dnd.EnemyEventKindCaptured,
dnd.EnemyEventKindFled,
dnd.EnemyEventKindKilled,
dnd.EnemyEventKindEngaged,
}
for index, kind := range want {
if result.Value.Events[index].Kind != kind {
t.Fatalf("event %d kind = %q, want %q", index, result.Value.Events[index].Kind, kind)
}
}
}
func TestNormalizeIsIdempotentAndPreservesEmptyRepresentation(t *testing.T) {
normalizer := newNormalizer(t, npcReferences(t))
for _, input := range []dnd.EnemyEventList{{}, {Events: []dnd.EnemyEvent{}}} {
result, err := normalizer.Normalize(context.Background(), normalizeRequest(testDocument(), input, contracts.ReferenceSet{}))
if err != nil || (result.Value.Events == nil) != (input.Events == nil) {
t.Fatalf("Normalize() = %#v, %v for %#v", result, err, input)
}
}
input := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: " Remaining orcs ", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}}
first, err := normalizer.Normalize(context.Background(), normalizeRequest(testDocument(), input, contracts.ReferenceSet{}))
if err != nil {
t.Fatal(err)
}
second, err := normalizer.Normalize(context.Background(), normalizeRequest(testDocument(), first.Value, contracts.ReferenceSet{}))
if err != nil || !reflect.DeepEqual(second.Value, first.Value) || len(second.Warnings) != 0 {
t.Fatalf("second normalization = %#v, %v", second, err)
}
}
func TestNormalizeRequiresRegistryAndKeepsOperationContentOutOfMetadata(t *testing.T) {
normalizer, err := New(Options{})
if err != nil {
t.Fatal(err)
}
if _, err := normalizer.Normalize(context.Background(), normalizeRequest(testDocument(), dnd.EnemyEventList{}, contracts.ReferenceSet{})); err == nil || !strings.Contains(err.Error(), "NPC registry") {
t.Fatalf("Normalize() error = %v", err)
}
operationReferences := npcReferences(t)
result, err := normalizer.Normalize(context.Background(), normalizeRequest(testDocument(), dnd.EnemyEventList{Events: []dnd.EnemyEvent{}}, operationReferences))
if err != nil || result.Value.Events == nil {
t.Fatalf("Normalize() = %#v, %v", result, err)
}
if metadata := normalizer.ManifestMetadata(); metadata["npc_registry_digest"] != nil {
t.Fatalf("operation registry leaked into metadata: %#v", metadata)
}
}
func TestNormalizerContractAndWarningBound(t *testing.T) {
normalizer := newNormalizer(t, npcReferences(t))
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
t.Fatal("DecodeOptions() accepted an unknown option")
}
first := ModuleSpec()
first.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed"
second := ModuleSpec()
if second.Key != Key || second.Stage != pipeline.StageNormalize || second.ExecutionClass != contracts.ExecutionClassDeterministic || second.ArtifactKind != dnd.EnemyEventListKind || second.ReferenceSlots[0].AcceptedMediaTypes[0] != "application/json" {
t.Fatalf("ModuleSpec() = %#v", second)
}
registry := pipeline.NewNormalizerRegistry()
if err := Register(registry); err != nil {
t.Fatal(err)
}
if spec, ok := registry.Spec(Key); !ok || spec.ArtifactKind != dnd.EnemyEventListKind {
t.Fatalf("registered spec = %#v, %t", spec, ok)
}
metadata := normalizer.ManifestMetadata()
if metadata["normalization_policy"] != normalizationPolicy || !strings.HasPrefix(metadata["npc_registry_digest"].(string), "sha256:") {
t.Fatalf("metadata = %#v", metadata)
}
encoded, err := json.Marshal(metadata)
if err != nil || strings.Contains(string(encoded), "Ária") {
t.Fatalf("unsafe metadata = %s, %v", encoded, err)
}
count := diagnostics.MaxWarnings + 5
document := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)}
input := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, count)}
for index := range document.Units {
document.Units[index].ID = index + 1
input.Events[index] = dnd.EnemyEvent{Name: " ÁRIA ", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: document.ID, StartUnitID: count - index, EndUnitID: count - index}}}
}
result, err := normalizer.Normalize(context.Background(), normalizeRequest(document, input, contracts.ReferenceSet{}))
if err != nil || len(result.Warnings) != diagnostics.MaxWarnings || result.Warnings[len(result.Warnings)-1].ReasonCode != ReasonCodeWarningsOmitted {
t.Fatalf("warnings = %#v, %v", result.Warnings, err)
}
}
func newNormalizer(t *testing.T, references contracts.ReferenceSet) *Normalizer {
t.Helper()
normalizer, err := New(Options{}, references)
if err != nil {
t.Fatal(err)
}
return normalizer
}
func normalizeRequest(document *source.SourceDocument, value dnd.EnemyEventList, references contracts.ReferenceSet) contracts.TypedNormalizeRequest[dnd.EnemyEventList] {
return contracts.TypedNormalizeRequest[dnd.EnemyEventList]{
Source: document,
MergeOutput: contracts.MergeArtifact[dnd.EnemyEventList]{Value: value},
References: references,
}
}
func testDocument() *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}}
}
func npcReferences(t *testing.T) contracts.ReferenceSet {
t.Helper()
value := dnd.NPCList{NPCs: []dnd.NPC{{
ID: identity.DeriveID("Ária"), Name: "Ária",
SourceRefs: []source.SourceRef{{SourceID: "registry", StartUnitID: 1, EndUnitID: 1}},
}}}
content, err := npccodec.New().Encode(value)
if err != nil {
t.Fatal(err)
}
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{NPCRegistryReferenceSlot: {
Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot},
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}},
}}}
}
func hasWarning(warnings []contracts.Warning, reason string) bool {
for _, warning := range warnings {
if warning.ReasonCode == reason {
return true
}
}
return false
}

View File

@@ -3,12 +3,14 @@ package register
import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
@@ -18,6 +20,11 @@ import (
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
enemyeventengagements "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/engagements"
enemyeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/invariants"
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
enemyeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_refs"
enemyeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_relatedness"
itemeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/invariants"
itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape"
itemeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_refs"
@@ -127,6 +134,34 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
},
})
}},
{name: "enemy events validator chain", register: func() error {
return registry.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageExtract,
Module: enemyeventextract.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(enemyeventshape.Key),
pipeline.Binding(enemyeventengagements.Key),
pipeline.Binding(enemyeventrefs.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(enemyeventrelatedness.Key),
},
})
}},
{name: "enemy events normalize validator chain", register: func() error {
return registry.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageNormalize,
Module: enemyeventnormalize.Key,
Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key),
pipeline.Binding(enemyeventshape.Key),
pipeline.Binding(enemyeventinvariants.Key),
pipeline.Binding(enemyeventrefs.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(enemyeventrelatedness.Key),
},
})
}},
{name: "item events validator chain", register: func() error {
return registry.Register(pipeline.ValidatorChainMapping{
Stage: pipeline.StageExtract,

View File

@@ -13,6 +13,9 @@ func registerEvidence(registry *pipeline.ArtifactEvidenceRegistry) error {
{name: "combat turns evidence", register: func() error {
return pipeline.RegisterArtifactEvidence(registry, dnd.CombatTurnListKind, combatTurnEvidence)
}},
{name: "enemy events evidence", register: func() error {
return pipeline.RegisterArtifactEvidence(registry, dnd.EnemyEventListKind, enemyEventEvidence)
}},
{name: "item events evidence", register: func() error {
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemEventListKind, itemEventEvidence)
}},
@@ -49,6 +52,14 @@ func combatTurnEvidence(value dnd.CombatTurnList) []source.SourceRef {
return append([]source.SourceRef(nil), refs...)
}
func enemyEventEvidence(value dnd.EnemyEventList) []source.SourceRef {
var refs []source.SourceRef
for _, record := range value.Events {
refs = append(refs, record.SourceRefs...)
}
return append([]source.SourceRef(nil), refs...)
}
func itemEventEvidence(value dnd.ItemEventList) []source.SourceRef {
var refs []source.SourceRef
for _, record := range value.Events {

View File

@@ -70,6 +70,27 @@ func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, err
return combined, nil
}
func appendEnemyEventLists(values []dnd.EnemyEventList) (dnd.EnemyEventList, error) {
count := 0
present := false
for _, value := range values {
if value.Events != nil {
present = true
}
count += len(value.Events)
}
if !present {
return dnd.EnemyEventList{}, nil
}
combined := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, 0, count)}
for _, value := range values {
for _, event := range value.Events {
combined.Events = append(combined.Events, cloneEnemyEvent(event))
}
}
return combined, nil
}
func appendItemEventLists(values []dnd.ItemEventList) (dnd.ItemEventList, error) {
count := 0
present := false
@@ -137,6 +158,12 @@ func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
return clone
}
func cloneEnemyEvent(value dnd.EnemyEvent) dnd.EnemyEvent {
clone := value
clone.SourceRefs = cloneSourceRefs(value.SourceRefs)
return clone
}
func cloneItemEvent(value dnd.ItemEvent) dnd.ItemEvent {
clone := value
if value.Quantity != nil {

View File

@@ -6,18 +6,21 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
enemyeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/enemyevents"
itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents"
interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
scenedescriptioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
@@ -33,6 +36,7 @@ func registerModules(registries pipeline.Registries) error {
{name: "spells codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, codec) }},
{name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }},
{name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }},
{name: "enemy events codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, enemyeventcodec.New()) }},
{name: "item events codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, itemeventcodec.New()) }},
{name: "npc interactions codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, interactioncodec.New()) }},
{name: "scene descriptions codec", register: func() error {
@@ -42,6 +46,7 @@ func registerModules(registries pipeline.Registries) error {
{name: "spells extractor", register: func() error { return spellextract.Register(registries.Extractors) }},
{name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }},
{name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }},
{name: "enemy events extractor", register: func() error { return enemyeventextract.Register(registries.Extractors) }},
{name: "item events extractor", register: func() error { return itemeventextract.Register(registries.Extractors) }},
{name: "npc interactions extractor", register: func() error { return interactionextract.Register(registries.Extractors) }},
{name: "scene descriptions extractor", register: func() error { return scenedescriptionextract.Register(registries.Extractors) }},
@@ -54,6 +59,9 @@ func registerModules(registries pipeline.Registries) error {
{name: "combat-turn-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists)
}},
{name: "enemy-event-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.EnemyEventListKind, appendEnemyEventLists)
}},
{name: "item-event-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemEventListKind, appendItemEventLists)
}},
@@ -66,6 +74,7 @@ func registerModules(registries pipeline.Registries) error {
{name: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }},
{name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }},
{name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }},
{name: "enemy events normalizer", register: func() error { return enemyeventnormalize.Register(registries.Normalizers) }},
{name: "item events normalizer", register: func() error { return itemeventnormalize.Register(registries.Normalizers) }},
{name: "npc interactions normalizer", register: func() error { return interactionnormalize.Register(registries.Normalizers) }},
{name: "scene descriptions normalizer", register: func() error { return scenedescriptionnormalize.Register(registries.Normalizers) }},
@@ -78,6 +87,9 @@ func registerModules(registries pipeline.Registries) error {
{name: "combat-turn-list noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind)
}},
{name: "enemy-event-list noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.EnemyEventList](registries.Normalizers, dnd.EnemyEventListKind)
}},
{name: "item-event-list noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.ItemEventList](registries.Normalizers, dnd.ItemEventListKind)
}},
@@ -97,6 +109,7 @@ func registerPromptAssets(assets *llm.AssetRegistry) error {
{name: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }},
{name: "npc normalization prompt assets", register: func() error { return npcnormalize.RegisterPromptAssets(assets) }},
{name: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }},
{name: "enemy events prompt assets", register: func() error { return enemyeventextract.RegisterPromptAssets(assets) }},
{name: "item events prompt assets", register: func() error { return itemeventextract.RegisterPromptAssets(assets) }},
{name: "npc interactions prompt assets", register: func() error { return interactionextract.RegisterPromptAssets(assets) }},
{name: "scene descriptions prompt assets", register: func() error { return scenedescriptionextract.RegisterPromptAssets(assets) }},

View File

@@ -9,6 +9,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
@@ -38,12 +39,11 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
"glossary": promptkit.Inline(glossarySentinel),
}
cases := []struct {
name string
promptID string
promptVersion string
inputs map[string]promptkit.ArtifactRef
npcInput bool
spellCatalogInput bool
name string
promptID string
promptVersion string
inputs map[string]promptkit.ArtifactRef
inputSentinels []string
}{
{name: "npcs", promptID: npcextract.PromptID, promptVersion: npcextract.SchemaVersion, inputs: commonInputs},
{name: "item events", promptID: itemeventextract.PromptID, promptVersion: itemeventextract.SchemaVersion, inputs: commonInputs},
@@ -55,7 +55,18 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
inputs: withPromptInputs(commonInputs, map[string]promptkit.ArtifactRef{
"npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`),
}),
npcInput: true,
inputSentinels: []string{npcSentinel},
},
{
name: "enemy events",
promptID: enemyeventextract.PromptID,
promptVersion: enemyeventextract.SchemaVersion,
inputs: withPromptInputs(commonInputs, map[string]promptkit.ArtifactRef{
"npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`),
"combat_turns": promptkit.Inline(`{"sentinel":"combat-turns-sentinel"}`),
"npc_interactions": promptkit.Inline(`{"sentinel":"npc-interactions-sentinel"}`),
}),
inputSentinels: []string{npcSentinel, "combat-turns-sentinel", "npc-interactions-sentinel"},
},
{
name: "npc interactions",
@@ -64,7 +75,7 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
inputs: withPromptInputs(commonInputs, map[string]promptkit.ArtifactRef{
"npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`),
}),
npcInput: true,
inputSentinels: []string{npcSentinel},
},
{
name: "spells",
@@ -74,8 +85,7 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
"npcs": promptkit.Inline(`{"sentinel":"` + npcSentinel + `"}`),
"spell_catalog": promptkit.Inline(`{"sentinel":"` + catalogSentinel + `"}`),
}),
npcInput: true,
spellCatalogInput: true,
inputSentinels: []string{npcSentinel, catalogSentinel},
},
}
@@ -93,9 +103,6 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
}
transcriptIndex := renderedInputMessageIndex(t, prepared.Messages, transcriptSentinel)
prefix := prepared.Messages[:transcriptIndex+1]
if len(prefix) != 4 {
t.Fatalf("messages through transcript = %d, want 4", len(prefix))
}
if len(prepared.Messages) <= len(prefix) {
t.Fatalf("prepared prompt has %d messages, want lane-specific suffix after transcript", len(prepared.Messages))
}
@@ -104,11 +111,8 @@ func TestExtractionPromptsShareRenderedPrefix(t *testing.T) {
} else if !reflect.DeepEqual(prefix, sharedPrefix) {
t.Fatalf("rendered prefix = %#v, want %#v", prefix, sharedPrefix)
}
if testCase.npcInput {
assertRenderedInputAfter(t, prepared.Messages, npcSentinel, transcriptIndex)
}
if testCase.spellCatalogInput {
assertRenderedInputAfter(t, prepared.Messages, catalogSentinel, transcriptIndex)
for _, sentinel := range testCase.inputSentinels {
assertRenderedInputAfter(t, prepared.Messages, sentinel, transcriptIndex)
}
})
}

View File

@@ -13,12 +13,14 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents"
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents"
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
@@ -44,6 +46,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
"dnd.spells/dnd.spells.yaml",
"dnd.npcs/dnd.npcs.yaml",
"dnd.combat_turns/dnd.combat_turns.yaml",
"dnd.enemy_events/dnd.enemy_events.yaml",
"dnd.item_events/dnd.item_events.yaml",
"dnd.npc_interactions/dnd.npc_interactions.yaml",
"dnd.scene_descriptions/dnd.scene_descriptions.yaml",
@@ -72,14 +75,15 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
t.Fatalf("normalization schema asset = %v, want registered private schema", err)
}
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"})
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, itemeventextract.Key, interactionextract.Key, scenedescriptionextract.Key})
assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, itemeventnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, pipeline.DefaultNormalizeModule})
assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, enemyeventextract.Key, itemeventextract.Key, interactionextract.Key, scenedescriptionextract.Key})
assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, enemyeventnormalize.Key, itemeventnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, pipeline.DefaultNormalizeModule})
assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCInteractionListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind})
@@ -96,6 +100,11 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
"extract/dnd/combat-turns/source_refs",
"extract/dnd/combat-turns/source_relatedness",
"normalize/dnd/combat-turns/invariants",
"extract/dnd/enemy-events/shape",
"extract/dnd/enemy-events/engagements",
"extract/dnd/enemy-events/source_refs",
"extract/dnd/enemy-events/source_relatedness",
"normalize/dnd/enemy-events/invariants",
"extract/dnd/item-events/shape",
"extract/dnd/item-events/source_refs",
"extract/dnd/item-events/source_relatedness",
@@ -168,6 +177,28 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) {
t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain)
}
enemyEventExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/enemy-events/shape"),
pipeline.Binding("extract/dnd/enemy-events/engagements"),
pipeline.Binding("extract/dnd/enemy-events/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/enemy-events/source_relatedness"),
}
enemyEventNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/enemy-events/shape"),
pipeline.Binding("normalize/dnd/enemy-events/invariants"),
pipeline.Binding("extract/dnd/enemy-events/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/enemy-events/source_relatedness"),
}
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, enemyeventextract.Key); !reflect.DeepEqual(got, enemyEventExtractChain) {
t.Fatalf("enemy event extract validator chain = %#v, want %#v", got, enemyEventExtractChain)
}
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, enemyeventnormalize.Key); !reflect.DeepEqual(got, enemyEventNormalizeChain) {
t.Fatalf("enemy event normalize validator chain = %#v, want %#v", got, enemyEventNormalizeChain)
}
itemEventExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("extract/dnd/item-events/shape"),
@@ -264,6 +295,10 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
"dnd.combat_turns/sharedassets/common-dnd-system.md",
"dnd.combat_turns/sharedassets/common-dnd-transcript.md",
"dnd.combat_turns/task.md",
"dnd.enemy_events/dnd.enemy_events.yaml",
"dnd.enemy_events/grounding.md",
"dnd.enemy_events/instructions.md",
"dnd.enemy_events/task.md",
"dnd.item_events/dnd.item_events.yaml",
"dnd.item_events/instructions.md",
"dnd.item_events/sharedassets/common-dnd-extraction-evidence.md",
@@ -289,6 +324,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
"dnd_spells_llm.v1.json",
"dnd_npcs_llm.v1.json",
"dnd_combat_turns_llm.v1.json",
"dnd_enemy_events_llm.v1.json",
"dnd_item_events_llm.v1.json",
"dnd_npc_interactions_llm.v1.json",
"dnd_scene_descriptions_llm.v1.json",
@@ -314,6 +350,19 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
if spec, ok := registries.Normalizers.Spec(combatnormalize.Key); !ok || spec.ArtifactKind != dnd.CombatTurnListKind || spec.Stage != pipeline.StageNormalize {
t.Fatalf("combat normalizer spec = %#v, present = %t; want dnd combat-turn-list artifact", spec, ok)
}
enemyEventExtractSpec, enemyEventExtractOK := registries.Extractors.Spec(enemyeventextract.Key)
enemyEventNormalizeSpec, enemyEventNormalizeOK := registries.Normalizers.Spec(enemyeventnormalize.Key)
if !enemyEventExtractOK || enemyEventExtractSpec.ArtifactKind != dnd.EnemyEventListKind || !enemyEventNormalizeOK || enemyEventNormalizeSpec.ArtifactKind != dnd.EnemyEventListKind || enemyEventNormalizeSpec.Stage != pipeline.StageNormalize {
t.Fatalf("enemy event specs = %#v / %#v, present = %t / %t", enemyEventExtractSpec, enemyEventNormalizeSpec, enemyEventExtractOK, enemyEventNormalizeOK)
}
if len(enemyEventExtractSpec.ReferenceSlots) != 8 || len(enemyEventNormalizeSpec.ReferenceSlots) != 1 {
t.Fatalf("enemy event reference slots = %#v / %#v", enemyEventExtractSpec.ReferenceSlots, enemyEventNormalizeSpec.ReferenceSlots)
}
npcRegistrySlot := referenceSlot(enemyEventExtractSpec.ReferenceSlots, "npcs")
enemyNormalizeRegistrySlot := referenceSlot(enemyEventNormalizeSpec.ReferenceSlots, "npcs")
if !npcRegistrySlot.Required || !enemyNormalizeRegistrySlot.Required || npcRegistrySlot.MaxBytes != enemyNormalizeRegistrySlot.MaxBytes || !reflect.DeepEqual(npcRegistrySlot.AcceptedMediaTypes, enemyNormalizeRegistrySlot.AcceptedMediaTypes) || !reflect.DeepEqual(npcRegistrySlot.AcceptedArtifactKinds, enemyNormalizeRegistrySlot.AcceptedArtifactKinds) {
t.Fatalf("enemy-event NPC registry slots disagree: %#v / %#v", enemyEventExtractSpec.ReferenceSlots, enemyEventNormalizeSpec.ReferenceSlots)
}
itemEventExtractSpec, itemEventExtractOK := registries.Extractors.Spec(itemeventextract.Key)
itemEventNormalizeSpec, itemEventNormalizeOK := registries.Normalizers.Spec(itemeventnormalize.Key)
if !itemEventExtractOK || itemEventExtractSpec.ArtifactKind != dnd.ItemEventListKind || !itemEventNormalizeOK || itemEventNormalizeSpec.ArtifactKind != dnd.ItemEventListKind || itemEventNormalizeSpec.Stage != pipeline.StageNormalize || len(itemEventNormalizeSpec.ReferenceSlots) != 0 {
@@ -361,6 +410,9 @@ func TestEvidenceProjectorsPreserveDirectReferencesWithIndependentStorage(t *tes
{name: "combat turns", project: func() []source.SourceRef {
return combatTurnEvidence(dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{SourceRefs: []source.SourceRef{first, second}}}})
}, want: []source.SourceRef{first, second}},
{name: "enemy events", project: func() []source.SourceRef {
return enemyEventEvidence(dnd.EnemyEventList{Events: []dnd.EnemyEvent{{SourceRefs: []source.SourceRef{first, second}}}})
}, want: []source.SourceRef{first, second}},
{name: "item events", project: func() []source.SourceRef {
return itemEventEvidence(dnd.ItemEventList{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{first, second}}}})
}, want: []source.SourceRef{first, second}},
@@ -536,6 +588,30 @@ func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) {
}
}
func TestAppendEnemyEventListsPreservesOrderPresenceAndOwnership(t *testing.T) {
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
input := []dnd.EnemyEventList{
{},
{Events: []dnd.EnemyEvent{}},
{Events: []dnd.EnemyEvent{{Name: "first", Kind: dnd.EnemyEventKindEngaged, SourceRefs: refs}}},
{Events: []dnd.EnemyEvent{{Name: "second", Kind: dnd.EnemyEventKindFled, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}},
}
got, err := appendEnemyEventLists(input)
if err != nil || got.Events == nil || !reflect.DeepEqual([]string{got.Events[0].Name, got.Events[1].Name}, []string{"first", "second"}) {
t.Fatalf("appendEnemyEventLists() = %#v, error = %v", got, err)
}
got.Events[0].SourceRefs[0].StartUnitID = 999
if input[2].Events[0].SourceRefs[0].StartUnitID != 1 {
t.Fatal("merged enemy events share source-reference storage")
}
for _, values := range [][]dnd.EnemyEventList{nil, []dnd.EnemyEventList{{}, {}}} {
result, err := appendEnemyEventLists(values)
if err != nil || result.Events != nil {
t.Fatalf("nil-only merge = %#v, %v; want nil events", result, err)
}
}
}
func TestAppendItemEventListsPreservesOrderPresenceAndOwnership(t *testing.T) {
quantity := 3
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
@@ -578,6 +654,11 @@ func TestAppendListsPreserveNestedSourceReferencePresence(t *testing.T) {
t.Fatalf("appendCombatTurnLists() = %#v, %v; want present-empty source refs", turns, err)
}
enemyEvents, err := appendEnemyEventLists([]dnd.EnemyEventList{{Events: []dnd.EnemyEvent{{SourceRefs: []source.SourceRef{}}}}})
if err != nil || enemyEvents.Events[0].SourceRefs == nil {
t.Fatalf("appendEnemyEventLists() = %#v, %v; want present-empty source refs", enemyEvents, err)
}
interactions, err := appendNPCInteractionLists([]dnd.NPCInteractionList{{Interactions: []dnd.NPCInteraction{{SourceRefs: []source.SourceRef{}}}}})
if err != nil || interactions.Interactions[0].SourceRefs == nil {
t.Fatalf("appendNPCInteractionLists() = %#v, %v; want present-empty source refs", interactions, err)

View File

@@ -7,6 +7,11 @@ import (
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
enemyeventengagements "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/engagements"
enemyeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/invariants"
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
enemyeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_refs"
enemyeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_relatedness"
itemeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/invariants"
itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape"
itemeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_refs"
@@ -46,6 +51,11 @@ func registerValidators(registries pipeline.Registries) error {
{name: "combat source references validator", register: func() error { return combatsourcerefs.Register(registries.Validators) }},
{name: "combat source relatedness validator", register: func() error { return combatrelatedness.Register(registries.Validators) }},
{name: "combat normalized invariants validator", register: func() error { return combatinvariants.Register(registries.Validators) }},
{name: "enemy event shape validator", register: func() error { return enemyeventshape.Register(registries.Validators) }},
{name: "enemy event engagement validator", register: func() error { return enemyeventengagements.Register(registries.Validators) }},
{name: "enemy event source references validator", register: func() error { return enemyeventrefs.Register(registries.Validators) }},
{name: "enemy event source relatedness validator", register: func() error { return enemyeventrelatedness.Register(registries.Validators) }},
{name: "enemy event normalized invariants validator", register: func() error { return enemyeventinvariants.Register(registries.Validators) }},
{name: "item event shape validator", register: func() error { return itemeventshape.Register(registries.Validators) }},
{name: "item event source references validator", register: func() error { return itemeventrefs.Register(registries.Validators) }},
{name: "item event source relatedness validator", register: func() error { return itemeventrelatedness.Register(registries.Validators) }},
@@ -77,6 +87,12 @@ func registerValidators(registries pipeline.Registries) error {
{name: "combat-turn-list always reject validator", register: func() error {
return alwaysreject.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
}},
{name: "enemy-event-list always accept validator", register: func() error {
return alwaysaccept.RegisterTyped[dnd.EnemyEventList](registries.Validators, dnd.EnemyEventListKind)
}},
{name: "enemy-event-list always reject validator", register: func() error {
return alwaysreject.RegisterTyped[dnd.EnemyEventList](registries.Validators, dnd.EnemyEventListKind)
}},
{name: "item-event-list always accept validator", register: func() error {
return alwaysaccept.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind)
}},

View File

@@ -18,6 +18,8 @@ const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-l
const ItemEventListKind contracts.ArtifactKind = "dnd/item-event-list"
const EnemyEventListKind contracts.ArtifactKind = "dnd/enemy-event-list"
type SpellList struct {
SpellCasts []SpellCast `json:"spell_casts"`
}
@@ -122,3 +124,23 @@ type ItemEvent struct {
To string `json:"to,omitempty"`
SourceRefs []source.SourceRef `json:"source_refs"`
}
type EnemyEventKind string
const (
EnemyEventKindEngaged EnemyEventKind = "engaged"
EnemyEventKindKilled EnemyEventKind = "killed"
EnemyEventKindFled EnemyEventKind = "fled"
EnemyEventKindCaptured EnemyEventKind = "captured"
EnemyEventKindIncapacitated EnemyEventKind = "incapacitated"
)
type EnemyEventList struct {
Events []EnemyEvent `json:"events"`
}
type EnemyEvent struct {
Name string `json:"name"`
Kind EnemyEventKind `json:"kind"`
SourceRefs []source.SourceRef `json:"source_refs"`
}

View File

@@ -0,0 +1,85 @@
// Package engagements prevents duplicate enemy engagements within one scene.
package engagements
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
)
const (
Key = "extract/dnd/enemy-events/engagements"
ReasonCode = "duplicate_enemy_engagement"
policy = "dnd.enemy_events.validator.engagements.v1"
)
type Options struct{}
type Validator struct{}
var _ contracts.TypedValidator[dnd.EnemyEventList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} }
func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.EnemyEventList]) (contracts.ValidationResult, error) {
if enemyeventshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil
}
seen := make(map[string]struct{})
for _, event := range req.Value.Events {
if event.Kind != dnd.EnemyEventKindEngaged {
continue
}
identity := enemyeventmodel.ComparisonKey(event.Name)
if identity == "" {
continue
}
if _, found := seen[identity]; found {
return contracts.ValidationResult{
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("duplicate enemy engagement", []string{
fmt.Sprintf("subject %s has more than one engagement in one combat scene", diagnostics.Quote(event.Name)),
}),
}, nil
}
seen[identity] = struct{}{}
}
return contracts.ValidationResult{Approved: true}, nil
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
}
func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.EnemyEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.EnemyEventList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(options), nil
})
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, err
}
return Options{}, nil
}
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }

View File

@@ -0,0 +1,77 @@
package engagements
import (
"context"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
func TestValidatorEnforcesOneEngagementPerSubject(t *testing.T) {
for _, test := range []struct {
name string
value dnd.EnemyEventList
approved bool
}{
{name: "empty", value: dnd.EnemyEventList{Events: []dnd.EnemyEvent{}}, approved: true},
{name: "one engagement", value: events(event("Ashfang", dnd.EnemyEventKindEngaged, 1)), approved: true},
{name: "same subject different evidence", value: events(event("Ashfang", dnd.EnemyEventKindEngaged, 1), event("Ashfang", dnd.EnemyEventKindEngaged, 2))},
{name: "whitespace and case variant", value: events(event("Ashfang", dnd.EnemyEventKindEngaged, 1), event(" ASHFANG ", dnd.EnemyEventKindEngaged, 2))},
{name: "different subjects", value: events(event("Ashfang", dnd.EnemyEventKindEngaged, 1), event("Briar", dnd.EnemyEventKindEngaged, 2)), approved: true},
{name: "different kinds", value: events(event("Ashfang", dnd.EnemyEventKindEngaged, 1), event("Ashfang", dnd.EnemyEventKindFled, 2)), approved: true},
{name: "malformed shape", value: dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang"}, {Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: refs(2)}}}, approved: true},
} {
t.Run(test.name, func(t *testing.T) {
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: test.value})
if err != nil || result.Approved != test.approved {
t.Fatalf("Validate() = %#v, %v; want approved=%t", result, err, test.approved)
}
if !test.approved && result.ReasonCode != ReasonCode {
t.Fatalf("reason code = %q, want %q", result.ReasonCode, ReasonCode)
}
})
}
validator := New(Options{})
for _, unitID := range []int{1, 2} {
value := events(event("Ashfang", dnd.EnemyEventKindEngaged, unitID))
before := events(event("Ashfang", dnd.EnemyEventKindEngaged, unitID))
result, err := validator.Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: value})
if err != nil || !result.Approved {
t.Fatalf("separate scene validation = %#v, %v; want approved", result, err)
}
if !reflect.DeepEqual(value, before) {
t.Fatalf("Validate() mutated separate scene value: got %#v, want %#v", value, before)
}
}
}
func TestValidatorRegistersStrictOptionsAndPolicy(t *testing.T) {
registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil {
t.Fatal(err)
}
if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
t.Fatalf("Spec() = %#v", Spec())
}
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
t.Fatalf("CheckpointFingerprints() = %#v", got)
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
t.Fatalf("DecodeOptions() error = %v, want strict option rejection", err)
}
}
func events(values ...dnd.EnemyEvent) dnd.EnemyEventList { return dnd.EnemyEventList{Events: values} }
func event(name string, kind dnd.EnemyEventKind, unitID int) dnd.EnemyEvent {
return dnd.EnemyEvent{Name: name, Kind: kind, SourceRefs: refs(unitID)}
}
func refs(unitID int) []source.SourceRef {
return []source.SourceRef{{SourceID: "session", StartUnitID: unitID, EndUnitID: unitID}}
}

View File

@@ -0,0 +1,170 @@
// Package invariants validates normalized D&D enemy-event artifacts.
package invariants
import (
"context"
"fmt"
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents"
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"
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
)
const (
Key = "normalize/dnd/enemy-events/invariants"
ReasonCode = "invalid_enemy_event_normalization"
policy = "dnd.enemy_events.validator.normalized.v1"
)
type Options struct{}
type Validator struct {
npcResolver *npcregistry.Resolver
}
var _ contracts.TypedValidator[dnd.EnemyEventList] = (*Validator)(nil)
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(_ Options, references ...contracts.ReferenceSet) (*Validator, error) {
if len(references) > 1 {
return nil, fmt.Errorf("enemy event invariants validator accepts at most one reference set")
}
var referenceSet contracts.ReferenceSet
if len(references) == 1 {
referenceSet = references[0]
}
resolver, err := npcregistry.NewResolver(referenceSet)
if err != nil {
return nil, fmt.Errorf("prepare NPC registry: %w", err)
}
return &Validator{npcResolver: resolver}, nil
}
func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (v *Validator) ManifestMetadata() map[string]any {
if v == nil || v.npcResolver == nil {
return nil
}
metadata := map[string]any{"policy": policy}
if seeded := v.npcResolver.Seeded(); seeded.Bound() {
metadata["npc_registry_digest"] = seeded.Digest()
}
return metadata
}
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if v == nil || v.npcResolver == nil {
return nil
}
return []pipeline.CheckpointFingerprint{
{Name: "policy", Value: policy},
{Name: "npc_registry", Value: v.npcResolver.Seeded().ProjectionDigest()},
}
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.EnemyEventList]) (contracts.ValidationResult, error) {
if enemyeventshape.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 {
return contracts.ValidationResult{}, fmt.Errorf("enemy event invariants validator must not be nil")
}
registry, err := v.npcResolver.Resolve(req.References)
if err != nil {
return contracts.ValidationResult{}, fmt.Errorf("resolve NPC registry: %w", err)
}
if !registry.Bound() {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: "invalid enemy event normalization: NPC registry reference is required"}, nil
}
issues := issuesFor(shared.NewSourceRefOrderFromIndex(index), req.Value, registry)
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid enemy event normalization", issues)}, nil
}
func allSourceRefsValid(index source.DocumentIndex, value dnd.EnemyEventList) bool {
for _, event := range value.Events {
for _, ref := range event.SourceRefs {
if index.ValidateRef(ref) != nil {
return false
}
}
}
return true
}
func issuesFor(order shared.SourceRefOrder, value dnd.EnemyEventList, registry *npcregistry.Registry) []string {
issues := make([]string, 0)
for eventIndex, event := range value.Events {
prefix := fmt.Sprintf("events[%d]", eventIndex)
if normalized := enemyeventmodel.NormalizeDisplay(event.Name); event.Name != normalized {
issues = append(issues, prefix+".name is not whitespace-normalized: "+diagnostics.Quote(event.Name))
}
if canonical, ok := registry.Lookup(event.Name); ok && event.Name != canonical.Name {
issues = append(issues, prefix+".name is not the canonical NPC display name: "+diagnostics.Quote(event.Name))
}
for refIndex := 1; refIndex < len(event.SourceRefs); refIndex++ {
previous := event.SourceRefs[refIndex-1]
current := event.SourceRefs[refIndex]
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))
}
}
}
if !sort.SliceIsSorted(value.Events, func(left, right int) bool {
return enemyeventmodel.Less(order, value.Events[left], value.Events[right])
}) {
issues = append(issues, "events are not in canonical order")
}
for eventIndex, event := range value.Events {
for previousIndex := 0; previousIndex < eventIndex; previousIndex++ {
if enemyeventmodel.ExactEqual(order, value.Events[previousIndex], event) {
issues = append(issues, fmt.Sprintf("events[%d] duplicates event %d", eventIndex, previousIndex))
break
}
}
}
return issues
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
}
func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.EnemyEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.EnemyEventList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(options, request.References)
})
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, err
}
return Options{}, nil
}
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }

View File

@@ -0,0 +1,145 @@
package invariants
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
)
func TestValidatorApprovesNormalizedEventsAndGenericGroups(t *testing.T) {
references := registryReferences(t, "Ária")
value := normalizedList()
result, err := newValidator(t, references).Validate(context.Background(), request(references, value))
if err != nil || !result.Approved {
t.Fatalf("Validate() = %#v, %v", result, err)
}
}
func TestValidatorRejectsNormalizationDrift(t *testing.T) {
references := registryReferences(t, "Ária")
for _, test := range []struct {
name string
mutate func(*dnd.EnemyEventList)
want string
}{
{"registry display", func(value *dnd.EnemyEventList) { value.Events[0].Name = " ária " }, "canonical NPC display name"},
{"generic whitespace", func(value *dnd.EnemyEventList) { value.Events[1].Name = " Remaining\tOrcs " }, "whitespace-normalized"},
{"evidence order", func(value *dnd.EnemyEventList) {
value.Events[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
}, "not in canonical order"},
{"duplicate evidence", func(value *dnd.EnemyEventList) {
value.Events[0].SourceRefs = append(value.Events[0].SourceRefs, value.Events[0].SourceRefs[0])
}, "duplicates the previous reference"},
{"event order", func(value *dnd.EnemyEventList) { value.Events[0], value.Events[1] = value.Events[1], value.Events[0] }, "events are not in canonical order"},
{"exact duplicate", func(value *dnd.EnemyEventList) { value.Events = append(value.Events, value.Events[0]) }, "duplicates event"},
} {
t.Run(test.name, func(t *testing.T) {
value := normalizedList()
test.mutate(&value)
result, err := newValidator(t, references).Validate(context.Background(), request(references, value))
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) {
t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want)
}
})
}
}
func TestValidatorDefersEarlierFailuresAndResolvesOperationRegistryWithoutMutation(t *testing.T) {
references := registryReferences(t, "Ária")
for _, value := range []dnd.EnemyEventList{
{Events: []dnd.EnemyEvent{{Name: "Ária"}}},
{Events: []dnd.EnemyEvent{{Name: "Ária", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}},
} {
result, err := newValidator(t, references).Validate(context.Background(), request(references, value))
if err != nil || !result.Approved {
t.Fatalf("deferral = %#v, %v", result, err)
}
}
value := normalizedList()
before := cloneList(value)
result, err := newValidator(t).Validate(context.Background(), request(references, value))
if err != nil || !result.Approved || !reflect.DeepEqual(value, before) {
t.Fatalf("operation registry = %#v, %v; value=%#v", result, err, value)
}
result, err = newValidator(t).Validate(context.Background(), request(contracts.ReferenceSet{}, normalizedList()))
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "required") {
t.Fatalf("unbound registry = %#v, %v", result, err)
}
}
func TestValidatorMetadataFingerprintsAndRegistrationAreSafe(t *testing.T) {
references := registryReferences(t, "Ária")
metadata, err := json.Marshal(newValidator(t, references).ManifestMetadata())
if err != nil || strings.Contains(string(metadata), "Ária") {
t.Fatalf("ManifestMetadata() = %s, %v", metadata, err)
}
if got := newValidator(t, references).CheckpointFingerprints(); len(got) != 2 || got[0].Value != policy || !strings.HasPrefix(got[1].Value, "sha256:") {
t.Fatalf("CheckpointFingerprints() = %#v", got)
}
registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil {
t.Fatal(err)
}
if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
t.Fatalf("Spec() = %#v", Spec())
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
t.Fatal("DecodeOptions() accepted unknown option")
}
}
func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator {
t.Helper()
validator, err := New(Options{}, references...)
if err != nil {
t.Fatal(err)
}
return validator
}
func request(references contracts.ReferenceSet, value dnd.EnemyEventList) contracts.TypedValidationRequest[dnd.EnemyEventList] {
return contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document(), References: references, Value: value}
}
func document() *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}}
}
func normalizedList() dnd.EnemyEventList {
return dnd.EnemyEventList{Events: []dnd.EnemyEvent{
{Name: "Ária", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
{Name: "Remaining Orcs", Kind: dnd.EnemyEventKindFled, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
}}
}
func cloneList(value dnd.EnemyEventList) dnd.EnemyEventList {
clone := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, len(value.Events))}
for index, event := range value.Events {
clone.Events[index] = event
clone.Events[index].SourceRefs = append([]source.SourceRef(nil), event.SourceRefs...)
}
return clone
}
func registryReferences(t *testing.T, names ...string) contracts.ReferenceSet {
t.Helper()
npcs := make([]dnd.NPC, len(names))
for index, name := range names {
npcs[index] = dnd.NPC{ID: identity.DeriveID(name), Name: name, SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: index + 1, EndUnitID: index + 1}}}
}
content, err := npccodec.New().Encode(dnd.NPCList{NPCs: npcs})
if err != nil {
t.Fatal(err)
}
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{npcregistry.ReferenceSlot: {Slot: contracts.ReferenceSlot{Name: npcregistry.ReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: npcregistry.ReferenceSlot, MediaType: npccodec.MediaType, Content: content, Origin: contracts.ReferenceOrigin{Type: "generated"}}}}}}
}

View File

@@ -0,0 +1,93 @@
// Package shape validates the candidate shape of D&D enemy-event artifacts.
package shape
import (
"context"
"fmt"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
)
const (
Key = "extract/dnd/enemy-events/shape"
ReasonCode = "invalid_enemy_event_shape"
policy = "dnd.enemy_events.validator.shape.v1"
)
type Options struct{}
type Validator struct{}
var _ contracts.TypedValidator[dnd.EnemyEventList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} }
func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.EnemyEventList]) (contracts.ValidationResult, error) {
if err := Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
}
return contracts.ValidationResult{Approved: true}, nil
}
func Validate(value dnd.EnemyEventList) error {
issues := issuesFor(value)
if len(issues) == 0 {
return nil
}
return fmt.Errorf("%s", diagnostics.Aggregate("invalid enemy event shape", issues))
}
func issuesFor(value dnd.EnemyEventList) []string {
if value.Events == nil {
return []string{"events must be present"}
}
issues := make([]string, 0)
for eventIndex, event := range value.Events {
prefix := fmt.Sprintf("events[%d]", eventIndex)
if strings.TrimSpace(event.Name) == "" {
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(event.Name))
}
if !enemyeventmodel.SupportedKind(event.Kind) {
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(event.Kind)))
}
if len(event.SourceRefs) == 0 {
issues = append(issues, prefix+".source_refs must contain at least one reference")
}
}
return issues
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
}
func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.EnemyEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.EnemyEventList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(options), nil
})
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, err
}
return Options{}, nil
}
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }

View File

@@ -0,0 +1,76 @@
package shape
import (
"context"
"strings"
"testing"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
func TestValidatorAcceptsEmptyAndWellFormedEventLists(t *testing.T) {
for _, value := range []dnd.EnemyEventList{{Events: []dnd.EnemyEvent{}}, validEventList()} {
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("Validate() = %#v, %v", result, err)
}
}
}
func TestValidatorRejectsOwnedShapeBoundaries(t *testing.T) {
for _, test := range []struct {
name string
mutate func(*dnd.EnemyEventList)
want string
}{
{"missing events", func(value *dnd.EnemyEventList) { value.Events = nil }, "events must be present"},
{"blank name", func(value *dnd.EnemyEventList) { value.Events[0].Name = " \t" }, "name must not be empty"},
{"unsupported kind", func(value *dnd.EnemyEventList) { value.Events[0].Kind = "unknown" }, "kind is unsupported"},
{"missing evidence", func(value *dnd.EnemyEventList) { value.Events[0].SourceRefs = nil }, "source_refs must contain"},
} {
t.Run(test.name, func(t *testing.T) {
value := validEventList()
test.mutate(&value)
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: value})
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) {
t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want)
}
})
}
}
func TestValidatorBoundsDiagnosticsAndRegistersStrictly(t *testing.T) {
value := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, 30)}
for index := range value.Events {
value.Events[index].Name = strings.Repeat("火", 300)
value.Events[index].Kind = "invalid"
}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: value})
if err != nil || result.Approved || !utf8.ValidString(result.Message) || len([]byte(result.Message)) > 4096 || !strings.Contains(result.Message, "additional issue(s) omitted") {
t.Fatalf("Validate() = %#v, %v", result, err)
}
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
t.Fatalf("CheckpointFingerprints() = %#v", got)
}
registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil {
t.Fatal(err)
}
if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
t.Fatalf("Spec() = %#v", Spec())
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
t.Fatal("DecodeOptions() accepted unknown option")
}
}
func validEventList() dnd.EnemyEventList {
return dnd.EnemyEventList{Events: []dnd.EnemyEvent{{
Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}
}

View File

@@ -0,0 +1,123 @@
// Package sourcerefs validates enemy-event evidence against the current source document.
package sourcerefs
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"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/diagnostics"
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
)
const (
Key = "extract/dnd/enemy-events/source_refs"
ReasonCode = "invalid_enemy_event_source_refs"
policy = "dnd.enemy_events.validator.source_refs.v1"
)
type Options struct{}
type Validator struct{}
var _ contracts.TypedValidator[dnd.EnemyEventList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} }
func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.EnemyEventList]) (contracts.ValidationResult, error) {
if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil {
return contracts.ValidationResult{}, fmt.Errorf("enemy event source-reference validator requires the current extraction chunk")
}
if err := enemyeventshape.Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
index := source.NewDocumentIndex(req.Source)
var coverage *chunkCoverage
if req.Stage == string(pipeline.StageExtract) {
coverage = newChunkCoverage(req.Chunk)
}
issues := sourceRefIssues(index, req.Source, coverage, req.Value)
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid enemy event source references", issues)}, nil
}
func sourceRefIssues(index source.DocumentIndex, doc *source.SourceDocument, coverage *chunkCoverage, value dnd.EnemyEventList) []string {
issues := make([]string, 0)
for eventIndex, event := range value.Events {
for refIndex, ref := range event.SourceRefs {
if err := index.ValidateRef(ref); err != nil {
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error())))
continue
}
if coverage != nil && !coverage.contains(doc, ref) {
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex))
}
}
}
return issues
}
type chunkCoverage struct {
sourceID string
unitIDs map[int]struct{}
}
func newChunkCoverage(chunk *source.Chunk) *chunkCoverage {
coverage := &chunkCoverage{sourceID: chunk.SourceID, unitIDs: make(map[int]struct{}, len(chunk.Units))}
for _, unit := range chunk.Units {
coverage.unitIDs[unit.ID] = struct{}{}
}
return coverage
}
func (coverage *chunkCoverage) contains(doc *source.SourceDocument, ref source.SourceRef) bool {
if coverage == nil || doc == nil || ref.SourceID != coverage.sourceID {
return false
}
start, startOK := source.UnitIndex(doc, ref.StartUnitID)
end, endOK := source.UnitIndex(doc, ref.EndUnitID)
if !startOK || !endOK || start > end {
return false
}
for position := start; position <= end; position++ {
if _, found := coverage.unitIDs[doc.Units[position].ID]; !found {
return false
}
}
return true
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
}
func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.EnemyEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.EnemyEventList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(options), nil
})
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, err
}
return Options{}, nil
}
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }

View File

@@ -0,0 +1,86 @@
package sourcerefs
import (
"context"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
func TestValidatorAcceptsCurrentDocumentAndChunkEvidence(t *testing.T) {
value := validEventList()
chunk := &source.Chunk{SourceID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Stage: string(pipeline.StageExtract), Source: document(), Chunk: chunk, Value: value})
if err != nil || !result.Approved {
t.Fatalf("Validate() = %#v, %v", result, err)
}
}
func TestValidatorRejectsInvalidOrOutOfChunkEvidence(t *testing.T) {
for _, test := range []struct {
name string
ref source.SourceRef
chunk *source.Chunk
want string
missing bool
}{
{"wrong source", source.SourceRef{SourceID: "other", StartUnitID: 1, EndUnitID: 1}, nil, "does not match document", false},
{"unknown unit", source.SourceRef{SourceID: "session", StartUnitID: 99, EndUnitID: 99}, nil, "was not found", false},
{"backward range", source.SourceRef{SourceID: "session", StartUnitID: 3, EndUnitID: 1}, nil, "appears after", false},
{"outside chunk", source.SourceRef{SourceID: "session", StartUnitID: 2, EndUnitID: 3}, &source.Chunk{SourceID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}}, "outside the current extraction chunk", false},
{"missing chunk", source.SourceRef{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, nil, "current extraction chunk", true},
} {
t.Run(test.name, func(t *testing.T) {
value := validEventList()
value.Events[0].SourceRefs[0] = test.ref
request := contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document(), Value: value}
if test.chunk != nil || test.missing {
request.Stage = string(pipeline.StageExtract)
request.Chunk = test.chunk
}
result, err := New(Options{}).Validate(context.Background(), request)
if test.missing {
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Validate() error = %v; want %q", err, test.want)
}
return
}
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) {
t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want)
}
})
}
}
func TestValidatorDefersShapeFailureAndRegistersStrictly(t *testing.T) {
malformed := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang"}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document(), Value: malformed})
if err != nil || !result.Approved {
t.Fatalf("shape deferral = %#v, %v", result, err)
}
registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil {
t.Fatal(err)
}
if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
t.Fatalf("Spec() = %#v", Spec())
}
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != policy {
t.Fatalf("CheckpointFingerprints() = %#v", got)
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
t.Fatal("DecodeOptions() accepted unknown option")
}
}
func validEventList() dnd.EnemyEventList {
return dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}}
}
func document() *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "Ashfang attacks."}, {ID: 2, Text: "Ashfang retreats."}, {ID: 3, Text: "Later."}}}
}

View File

@@ -0,0 +1,84 @@
// Package sourcerelatedness reports advisory enemy-subject evidence concerns.
package sourcerelatedness
import (
"context"
"fmt"
"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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
)
const (
Key = "extract/dnd/enemy-events/source_relatedness"
WarningReasonCode = "enemy_event_not_near_source"
OmittedReasonCode = "enemy_event_relatedness_warnings_omitted"
policy = "dnd.enemy_events.validator.source_relatedness.v1"
)
type Options struct{}
type Validator struct{}
var _ contracts.TypedValidator[dnd.EnemyEventList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} }
func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.EnemyEventList]) (contracts.ValidationResult, error) {
if enemyeventshape.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
}
warnings := make([]contracts.Warning, 0)
for eventIndex, event := range req.Value.Events {
citedText, err := resolver.CitedText(event.SourceRefs)
if err != nil || shared.ContainsTokenSequence(citedText, event.Name) {
continue
}
warnings = append(warnings, contracts.Warning{
Scope: fmt.Sprintf("events[%d]", eventIndex),
ReasonCode: WarningReasonCode,
Message: diagnostics.Aggregate("enemy event subject not near source", []string{
fmt.Sprintf("subject %s was not found in cited source text", diagnostics.Quote(event.Name)),
}),
})
}
return contracts.ValidationResult{Approved: true, Warnings: diagnostics.LimitWarnings(warnings, "enemy_events", OmittedReasonCode)}, nil
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
}
func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.EnemyEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.EnemyEventList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(options), nil
})
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, err
}
return Options{}, nil
}
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }

View File

@@ -0,0 +1,66 @@
package sourcerelatedness
import (
"context"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
func TestValidatorUsesOnlyCitedTranscriptEvidence(t *testing.T) {
value := validEventList()
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"npcs": {Items: []contracts.ReferenceItem{{Content: []byte("Ashfang")}}}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("The party waits."), References: references, Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != WarningReasonCode {
t.Fatalf("Validate() = %#v, %v", result, err)
}
}
func TestValidatorAcceptsUnicodeSubjectInCitedEvidence(t *testing.T) {
value := validEventList()
value.Events[0].Name = "O'Rin Thorn"
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("orin\u2003thorn flees."), Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("Validate() = %#v, %v", result, err)
}
}
func TestValidatorDefersMalformedValuesAndBoundsWarnings(t *testing.T) {
malformed := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang"}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("none"), Value: malformed})
if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("shape deferral = %#v, %v", result, err)
}
value := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, 30)}
for index := range value.Events {
value.Events[index] = dnd.EnemyEvent{Name: "Missing", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
}
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("none"), Value: value})
if err != nil || !result.Approved || len(result.Warnings) == 0 || result.Warnings[len(result.Warnings)-1].ReasonCode != OmittedReasonCode {
t.Fatalf("bounded warnings = %#v, %v", result, err)
}
registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil {
t.Fatal(err)
}
if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
t.Fatalf("Spec() = %#v", Spec())
}
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != policy {
t.Fatalf("CheckpointFingerprints() = %#v", got)
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
t.Fatal("DecodeOptions() accepted unknown option")
}
}
func validEventList() dnd.EnemyEventList {
return dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang", Kind: dnd.EnemyEventKindFled, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}
}
func document(text string) *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: text}}}
}