Compare commits

...

9 Commits

108 changed files with 2449 additions and 3223 deletions

View File

@@ -0,0 +1,98 @@
# ADR-0009: Prefer minimal evidence-grounded extraction artifacts
**Status:** Accepted
**Date:** 2026-07-22
## Context
Notarius is intended to extract structured facts from source material. Several
early D&D artifacts grew to include descriptive prose, inferred relationships,
immediate outcomes, summaries, and other enrichment alongside the facts that
identify an event or entity. Those fields make one model call responsible for
both extraction and synthesis.
In practice, the richer contracts have produced overlapping or weakly grounded
fields and have made structurally valid, semantically coherent output harder for
cost-effective smaller models. They also increase prompt size, validation and
normalization policy, durable schema surface, downstream coupling, and the
number of claims whose provenance must be evaluated.
The application needs a consistent rule for deciding what belongs in an
extractor before redesigning the current D&D spell, NPC, and combat-turn
contracts or adding new artifact families.
## Decision
An extraction module answers one narrowly stated question and returns the
smallest durable structured artifact that usefully answers it.
Every model-produced field in an extraction artifact must:
- be necessary to answer the extractor's stated question or serve a known
downstream consumer;
- represent a fact or bounded classification that can be supported directly by
cited source ranges;
- remain independently meaningful without model-generated explanatory prose;
and
- justify the additional prompt, schema, validation, normalization, and
compatibility surface it creates.
Source references are required provenance for extracted records. Auxiliary
references may disambiguate identities or canonical names, but they do not
establish source facts and are not copied into evidence.
Extraction artifacts do not include narrative summaries, general analysis,
speculative enrichment, inferred biography or relationships, or redundant
free-text descriptions by default. When such output has a demonstrated use, it
belongs in an explicitly named extraction, classification, enrichment, or
analysis module with its own contract and evidence policy.
Occurrence-level facts are not forced into entity-level attributes. A fact
that can change between encounters, such as an NPC's role in a scene, belongs
on an occurrence artifact rather than as one scalar property of a normalized
NPC registry entry.
Deterministic mapping and normalization may assign application-owned
identifiers, canonicalize known catalog values, order and deduplicate evidence,
and collapse records under an explicit identity rule. They must not manufacture
removed descriptive fields or synthesize missing claims to satisfy an older
contract.
This is a default design rule, not a prohibition on rich artifacts. A richer
field is appropriate when its consumer, evidence semantics, and ownership are
explicit.
## Alternatives considered
- Keep rich schemas and improve prompts or use larger models. This retains
potentially convenient prose but does not resolve overlapping field
responsibilities, weak provenance, higher cost, or unnecessary downstream
coupling.
- Make enrichment fields optional. This reduces rejection pressure but leaves
ambiguous artifact semantics and inconsistent records, and many strict
structured-output providers still require nullable placeholders.
- Keep minimal private LLM schemas while preserving rich durable artifacts.
Deterministic code would have to invent, default, or separately derive the
missing fields, hiding synthesis behind the extraction boundary.
- Use one broad session-analysis module. This reduces the number of lanes but
couples unrelated facts, schemas, retries, evaluation, and downstream
consumers into one model call.
## Consequences
Extraction prompts and response schemas become smaller, more focused, and more
suitable for lower-cost models. Artifacts carry fewer unsupported claims, and
their evidence and validation policies become easier to explain and evaluate.
Independent extractors can evolve, retry, and be consumed without requiring
unrelated enrichment.
Some descriptive convenience fields will disappear from primary artifacts.
Consumers that genuinely need them may require a separate module and explicit
pipeline step. Entity registries may no longer resolve aliases or relationships
unless a dedicated, evidence-grounded capability supplies them.
Removing durable fields is a schema compatibility change. Each affected
artifact requires an explicit version and reference policy; private prompt
changes alone are insufficient. Current-behavior integration and internal
documentation must change with implementation, while the roadmap owns the
proposed contract until then.

View File

@@ -358,7 +358,7 @@ production validators do not call the LLM and must not set `llm_profile`.
| merge | `appendorder` | Combines typed artifacts in chunk order. | | merge | `appendorder` | Combines typed artifacts in chunk order. |
| normalize | `noop` | Passes merged typed artifacts through unchanged. | | normalize | `noop` | Passes merged typed artifacts through unchanged. |
| normalize | `dnd/spells` | Deterministically canonicalizes and de-duplicates typed D&D spell-list artifacts. | | normalize | `dnd/spells` | Deterministically canonicalizes and de-duplicates typed D&D spell-list artifacts. |
| normalize | `dnd/npcs` | Deterministically consolidates typed D&D NPC-list artifacts by canonical identity and aliases. | | normalize | `dnd/npcs` | Deterministically consolidates typed D&D NPC-list artifacts by canonical name and unions exact evidence. |
| normalize | `dnd/combat-turns` | Deterministically canonicalizes, orders, and de-duplicates typed D&D combat-turn artifacts. | | normalize | `dnd/combat-turns` | Deterministically canonicalizes, orders, and de-duplicates typed D&D combat-turn artifacts. |
| output | `json` | Produces JSON output files for normalized `application/json` lanes. | | output | `json` | Produces JSON output files for normalized `application/json` lanes. |
@@ -376,12 +376,12 @@ production validators do not call the LLM and must not set `llm_profile`.
| `extract/dnd/spells/source_relatedness` | deterministic | Emits warnings when a spell name is not found near its cited source text. | | `extract/dnd/spells/source_relatedness` | deterministic | Emits warnings when a spell name is not found near its cited source text. |
| `extract/dnd/npcs/shape` | deterministic | Rejects malformed D&D NPC-list artifacts. | | `extract/dnd/npcs/shape` | deterministic | Rejects malformed D&D NPC-list artifacts. |
| `extract/dnd/npcs/source_refs` | deterministic | Rejects missing or invalid D&D NPC source references. | | `extract/dnd/npcs/source_refs` | deterministic | Rejects missing or invalid D&D NPC source references. |
| `extract/dnd/npcs/source_relatedness` | deterministic | Emits warnings when an NPC name or alias is not found near its cited source text. | | `extract/dnd/npcs/source_relatedness` | deterministic | Emits warnings when an NPC name is not found near its cited source text. |
| `normalize/dnd/npcs/identity` | deterministic | Rejects invalid canonical IDs, aliases, and cross-record identity collisions. | | `normalize/dnd/npcs/identity` | deterministic | Rejects invalid canonical IDs and duplicate canonical-name or ID ownership. |
| `extract/dnd/combat-turns/shape` | deterministic | Rejects malformed D&D combat-turn artifacts. | | `extract/dnd/combat-turns/shape` | deterministic | Rejects malformed D&D combat-turn artifacts. |
| `extract/dnd/combat-turns/source_refs` | deterministic | Rejects missing or invalid D&D combat-turn source references. | | `extract/dnd/combat-turns/source_refs` | deterministic | Rejects missing or invalid D&D combat-turn source references. |
| `extract/dnd/combat-turns/source_relatedness` | deterministic | Emits warnings when an actor or declared action is not found near cited source text. | | `extract/dnd/combat-turns/source_relatedness` | deterministic | Emits warnings when an actor is not found near cited source text. |
| `normalize/dnd/combat-turns/invariants` | deterministic | Rejects normalized combat-turn identity, target, evidence-order, and chronology violations. | | `normalize/dnd/combat-turns/invariants` | deterministic | Rejects normalized combat-turn identity, evidence-order, and chronology violations. |
The production default chain for `dnd/spells` is used for both its extract and The production default chain for `dnd/spells` is used for both its extract and
normalize stages: normalize stages:
@@ -470,13 +470,13 @@ slot accepts exactly one `application/json` artifact no larger than 1 MiB. An
external file is decoded and identity-validated during preparation. A external file is decoded and identity-validated during preparation. A
generated binding is validated at the step handoff and is provided to the generated binding is validated at the step handoff and is provided to the
operation through the same reference contract. In both cases, the model operation through the same reference contract. In both cases, the model
receives canonical JSON for caster-name grounding. Registry source references receives a names-only JSON projection for caster-name grounding. Registry source references
may belong to the NPC-producing session and are provenance only; they are not may belong to the NPC-producing session and are provenance only; they are not
spell evidence. Generated reference identity and bounded producer provenance spell evidence. Generated reference identity and bounded producer provenance
are recorded by the framework; NPC names, aliases, content, and paths are not are recorded by the framework; NPC names, content, and paths are not copied
copied into manifests or checkpoint decisions. When absent, the prompt receives into manifests. Consumer-local checkpoint identity uses the names-only
the exact empty value `{"npcs":[]}` and no registry provenance or fingerprint projection digest. When absent, the prompt receives the exact empty value
is recorded. `{"npcs":[]}` with its projection digest and no registry provenance.
The `dnd/spells` normalizer declares the same optional `spell_catalog` slot. The `dnd/spells` normalizer declares the same optional `spell_catalog` slot.
When an overlay is used, bind it independently under When an overlay is used, bind it independently under
@@ -495,7 +495,7 @@ explicit ordered step.
The `dnd/combat-turns` extractor declares the optional campaign slots and the The `dnd/combat-turns` extractor declares the optional campaign slots and the
structured `npcs` slot. Campaign references guide only the LLM extraction structured `npcs` slot. Campaign references guide only the LLM extraction
stage. The deterministic normalizer declares only `npcs`, whose operation-time stage. The deterministic normalizer declares only `npcs`, whose operation-time
registry supports the same actor and target canonicalization. Each `npcs` slot registry supports the same actor canonicalization. Each `npcs` slot
accepts exactly one UTF-8 `application/json` artifact no larger than 1 MiB. The accepts exactly one UTF-8 `application/json` artifact no larger than 1 MiB. The
registry's source ranges remain provenance for the reference and never become registry's source ranges remain provenance for the reference and never become
combat evidence. An ordered step binding fans the same generated NPC artifact combat evidence. An ordered step binding fans the same generated NPC artifact
@@ -512,9 +512,9 @@ references:
When bound, the combat extractor and normalizer receive the generated registry When bound, the combat extractor and normalizer receive the generated registry
at operation time. Framework provenance and checkpoint dependencies contain its at operation time. Framework provenance and checkpoint dependencies contain its
kind, schema identity, media type, canonical digest, size, and bounded producer kind, schema identity, media type, canonical digest, size, and bounded producer
identity; names, aliases, content, and paths are not recorded there. When identity; names, content, and paths are not recorded there. When
absent, the combat prompt receives the exact empty registry value absent, the combat prompt receives the exact empty registry value
`{"npcs":[]}` and no registry provenance or fingerprint is recorded. `{"npcs":[]}` with its projection digest and no registry provenance.
## State Surfaces ## State Surfaces

View File

@@ -25,20 +25,8 @@ Each combat turn contains these required fields:
| --- | --- | | --- | --- |
| `actor` | Non-empty string. | | `actor` | Non-empty string. |
| `turn_kind` | One of `turn`, `reaction`, `legendary_action`, `lair_action`, or `other`. | | `turn_kind` | One of `turn`, `reaction`, `legendary_action`, `lair_action`, or `other`. |
| `round` | Required JSON field containing a positive integer or `null`. |
| `actions` | Required array with at least one action. |
| `summary` | Non-empty string. |
| `source_refs` | Required array with at least one source reference. | | `source_refs` | Required array with at least one source reference. |
Each action contains these required fields:
| Field | Shape |
| --- | --- |
| `category` | One of `attack`, `spell`, `movement`, `item`, `ability_check`, `saving_throw`, `condition`, or `other`. |
| `declaration` | Non-empty string describing what was declared. |
| `targets` | Required array of strings; the array may be empty, but entries may not be empty. |
| `resolution` | Required JSON field containing a non-empty string or `null`. |
Source references use the shared source-reference shape: Source references use the shared source-reference shape:
```json ```json
@@ -58,11 +46,10 @@ boundary.
The codec exposes two representations of the same typed artifact: The codec exposes two representations of the same typed artifact:
- Candidate encode/decode preserves invalid enum values, nullable values, - Candidate encode/decode preserves invalid actor and turn-kind values,
required-array presence, required strings, targets, and source references so collection presence, and source references so later validators can report
later validators can report them. Candidate decoding still requires valid them. Candidate decoding still requires valid JSON, one JSON value, known
JSON, one JSON value, known fields, and the explicitly present `round` and fields, and compatible JSON types.
`resolution` keys; `null` is distinct from a missing key.
- Approved encode/decode enforces the structural rules in this contract. - Approved encode/decode enforces the structural rules in this contract.
The codec owns the durable JSON Schema, whose object layers all set The codec owns the durable JSON Schema, whose object layers all set
@@ -96,9 +83,9 @@ An external file is validated during preparation. In an ordered pipeline, the
same slot may receive the producer's canonical generated artifact at the step same slot may receive the producer's canonical generated artifact at the step
handoff. handoff.
The private response envelope has the same fields and JSON types as the durable The private response envelope has the same turn fields and JSON types as the
turn/action shape except that source references contain only `start_unit_id` durable shape except that source references contain only `start_unit_id`
and `end_unit_id`. It enforces required and nullable field presence, types, and and `end_unit_id`. It enforces required field presence, types, and
unknown-field rejection, while deterministic validators own enum membership, unknown-field rejection, while deterministic validators own enum membership,
non-empty values and collections, and positive-number requirements. The non-empty values and collections, and positive-number requirements. The
extractor assigns the current source ID, removes exact duplicate ranges, and extractor assigns the current source ID, removes exact duplicate ranges, and
@@ -113,20 +100,18 @@ The standalone validator keys are:
| Validator | Responsibility | | Validator | Responsibility |
| --- | --- | | --- | --- |
| `extract/dnd/combat-turns/shape` | Required arrays, strings, nullable fields, positive rounds, and supported enum values. | | `extract/dnd/combat-turns/shape` | Required list, actor, turn kind, and source references, plus supported turn-kind values. |
| `extract/dnd/combat-turns/source_refs` | Source identity, source-unit existence, and range order through the source document. | | `extract/dnd/combat-turns/source_refs` | Source identity, source-unit existence, and range order through the source document. |
| `extract/dnd/combat-turns/source_relatedness` | At most one advisory warning per turn when the actor or declared action is not related to cited transcript text. | | `extract/dnd/combat-turns/source_relatedness` | At most one advisory warning per turn when the actor is not related to cited transcript text. |
Source-reference and relatedness validators defer malformed shape to the shape Source-reference and relatedness validators defer malformed shape to the shape
validator. Relatedness also defers when any cited source range is invalid. It validator. Relatedness also defers when any cited source range is invalid. It
combines overlapping cited ranges once in document order, compares actors with combines overlapping cited ranges once in document order and compares actors
the shared Unicode-aware NPC identity policy, and checks declaration tokens of with the shared Unicode-aware NPC identity policy.
at least four Unicode code points against complete cited-text tokens. Targets
are not checked deterministically.
The production D&D registrar exposes the extractor and these validators. Its The production D&D registrar exposes the extractor and these validators. Its
default extraction chain preserves this order: JSON syntax, private response default extraction chain preserves this order: JSON syntax, combat shape,
schema, combat shape, source references, then source relatedness. source references, private response schema, then source relatedness.
## Normalization boundary ## Normalization boundary
@@ -139,14 +124,12 @@ time handoff. Runtime normalization uses that immutable prepared or handed-off
view. view.
Normalization policy is `dnd.combat_turns.normalize.v1`. It display-normalizes Normalization policy is `dnd.combat_turns.normalize.v1`. It display-normalizes
actor, summary, declarations, targets, and non-null resolutions; canonicalizes the actor, canonicalizes exact registry actor matches, orders and deduplicates
exact registry actor and target matches; orders and deduplicates exact source exact source references, stable-sorts records by earliest valid source-document
references; stable-sorts records by earliest valid source-document position; and position, and collapses only records with the same actor identity, turn kind,
collapses only records with the same actor identity, turn kind, round value, and and complete valid evidence set. The first normalized record is retained.
complete valid evidence set. The first normalized record is retained without Invalid evidence is never eligible for duplicate collapse. Every mutation and
merging its actions or prose. Invalid evidence is never eligible for duplicate collapse emits a bounded warning using the merged input index in its scope.
collapse. Every mutation and collapse emits a bounded warning using the merged
input index in its scope.
The normalizer reports `normalization_policy` and `identity_policy` metadata The normalizer reports `normalization_policy` and `identity_policy` metadata
and fingerprints. An external registry may additionally contribute and fingerprints. An external registry may additionally contribute
@@ -154,14 +137,14 @@ and fingerprints. An external registry may additionally contribute
in framework handoff provenance and dependency fingerprints. The in framework handoff provenance and dependency fingerprints. The
normalized-invariants validator is normalized-invariants validator is
`normalize/dnd/combat-turns/invariants`; it defers shape and source-reference `normalize/dnd/combat-turns/invariants`; it defers shape and source-reference
failures, then checks display normalization, target identity uniqueness, failures, then checks actor display normalization, canonical evidence ordering,
canonical evidence ordering, chronology, and duplicate identity. It rejects chronology, and duplicate identity. It rejects
with `invalid_combat_turn_normalization` under policy with `invalid_combat_turn_normalization` under policy
`dnd.combat_turns.validator.normalized.v1`. `dnd.combat_turns.validator.normalized.v1`.
The production D&D registrar exposes the normalizer and normalized-invariants The production D&D registrar exposes the normalizer and normalized-invariants
validator. Its default normalization chain is JSON syntax, durable schema, validator. Its default normalization chain is JSON syntax, combat shape,
combat shape, normalized invariants, source references, then source normalized invariants, source references, durable schema, then source
relatedness. The lane uses the framework's typed append-order merger and has no relatedness. The lane uses the framework's typed append-order merger and has no
merge validator chain. merge validator chain.
@@ -172,7 +155,9 @@ The selectable lane uses extractor and normalizer key `dnd/combat-turns`,
reference contributes raw-file provenance to the run manifest. A generated reference contributes raw-file provenance to the run manifest. A generated
binding contributes artifact kind, schema identity, media type, canonical binding contributes artifact kind, schema identity, media type, canonical
digest, size, and bounded producer provenance. Consumer metadata and checkpoint digest, size, and bounded producer provenance. Consumer metadata and checkpoint
fingerprints contain no registry names, aliases, content, paths, or NPC source fingerprints contain no registry names, content, paths, or NPC source ranges.
ranges. The normalized lane is emitted as `lanes/<lane-id>.json` by the JSON The component-local registry fingerprint covers only the names projected to the
consumer, while manifest provenance retains the full artifact digest. The
normalized lane is emitted as `lanes/<lane-id>.json` by the JSON
output module, and warnings and rejection summaries remain in their shared output module, and warnings and rejection summaries remain in their shared
companion files. companion files.

View File

@@ -3,7 +3,7 @@
This document defines the durable D&D NPC-list artifact, its JSON codec, and This document defines the durable D&D NPC-list artifact, its JSON codec, and
the selectable production NPC pipeline. The normalized JSON payload can be the selectable production NPC pipeline. The normalized JSON payload can be
passed explicitly to the spell extractor as an optional caster-name registry passed explicitly to the spell extractor as an optional caster-name registry
or to the combat extractor and normalizer as an actor/target registry. It or to the combat extractor and normalizer as an actor registry. It
remains a reference, not spell or combat evidence. remains a reference, not spell or combat evidence.
## Identity ## Identity
@@ -20,6 +20,11 @@ the Unicode-normalized, case-folded canonical name using the identity policy.
The durable codec enforces the artifact shape and ID syntax; registry identity The durable codec enforces the artifact shape and ID syntax; registry identity
validation remains a separate deterministic concern. validation remains a separate deterministic concern.
The extractor's private LLM response schema is a separate structural transport
contract. It omits framework-assigned NPC and source IDs and admits semantic
candidates for the deterministic shape and source-reference validators; it is
not part of this durable contract.
## Output Shape ## Output Shape
The payload is one object with a required top-level `npcs` array: The payload is one object with a required top-level `npcs` array:
@@ -36,13 +41,9 @@ Each NPC contains exactly these required fields:
- `id`: `npc:sha256:` followed by 64 lowercase hexadecimal characters; - `id`: `npc:sha256:` followed by 64 lowercase hexadecimal characters;
- `name`: the canonical display name; - `name`: the canonical display name;
- `aliases`: an array of alternate display names, which may be empty;
- `description`: a concise description;
- `relationships`: an array of target/relationship objects, which may be empty;
- `source_refs`: at least one source reference supporting the NPC record. - `source_refs`: at least one source reference supporting the NPC record.
Each relationship contains required `target` and `relationship` strings. Each Each source reference contains required `source_id`, `start_unit_id`, and
source reference contains required `source_id`, `start_unit_id`, and
`end_unit_id`; unit IDs are positive integers. Source document identity, unit `end_unit_id`; unit IDs are positive integers. Source document identity, unit
existence, and range ordering are validated by the source-reference validator existence, and range ordering are validated by the source-reference validator
when the artifact is used by a pipeline. when the artifact is used by a pipeline.
@@ -71,25 +72,29 @@ The production identities are:
The extractor maps private model records to the current source identity and The extractor maps private model records to the current source identity and
assigns deterministic IDs. Extraction validation checks shape, source assigns deterministic IDs. Extraction validation checks shape, source
references, and source relatedness. The normalizer then consolidates records references, and source relatedness. The normalizer then consolidates records
by canonical identity or canonical-name/alias matches, preserves the first only when their normalized canonical names match, preserves the first record's
record's display and output position, unions relationships and exact evidence, display and output position, unions exact evidence, and validates the retained
rewrites unambiguous relationship targets to canonical names, and validates registry's identity. No LLM is used for consolidation.
the retained registry's identity. No LLM is used for consolidation.
The extraction prompt asks only for individually identifiable NPC names backed
by source evidence. Groups, generic roles, invented labels, and descriptive or
relationship enrichment are outside the contract.
The default extraction chain is `generic/valid_json`, The default extraction chain is `generic/valid_json`,
`generic/valid_json_schema`, `extract/dnd/npcs/shape`, `extract/dnd/npcs/shape`, `extract/dnd/npcs/source_refs`,
`extract/dnd/npcs/source_refs`, and `generic/valid_json_schema`, and `extract/dnd/npcs/source_relatedness`. The
`extract/dnd/npcs/source_relatedness`. The normalize chain adds default normalize chain is `generic/valid_json`, `extract/dnd/npcs/shape`,
`normalize/dnd/npcs/identity` before the source-reference and relatedness `normalize/dnd/npcs/identity`, `extract/dnd/npcs/source_refs`,
checks. Relatedness emits bounded warnings when an NPC canonical name or `generic/valid_json_schema`, and `extract/dnd/npcs/source_relatedness`.
alias is not present near its cited transcript text; opaque campaign Relatedness emits bounded warnings when an NPC canonical name is not present
near its cited transcript text; opaque campaign
references may explain such a warning but do not become evidence. references may explain such a warning but do not become evidence.
## Manifest And Artifact Handoff ## Manifest And Artifact Handoff
The NPC extractor records prompt and response-schema identities. The durable The NPC extractor records prompt and response-schema identities. The durable
codec records only `npc_count`; raw names, aliases, descriptions, source codec records only `npc_count`; raw names, source references, and payload bytes
references, and payload bytes stay in the lane file rather than manifest stay in the lane file rather than manifest
metadata. The normalized lane can be consumed by a later ordered step through metadata. The normalized lane can be consumed by a later ordered step through
the registered canonical codec: the registered canonical codec:
@@ -119,9 +124,16 @@ The framework hands only an accepted normalized artifact across the barrier. It
validates the canonical bytes against each consumer slot and clones the validates the canonical bytes against each consumer slot and clones the
operation-time reference for the spell and combat consumers. Generated operation-time reference for the spell and combat consumers. Generated
provenance records the artifact kind, schema identity, media type, canonical provenance records the artifact kind, schema identity, media type, canonical
digest, size, and producer step/lane/module, but not names, aliases, source digest, size, and producer step/lane/module, but not names, source
ranges, or payload bytes. External normalized files remain supported as ranges, or payload bytes. External normalized files remain supported as
explicit references and retain their file provenance. explicit references and retain their file provenance.
NPC source references are registry provenance and are never accepted as spell NPC source references are registry provenance and are never accepted as spell
or combat evidence. Current transcript units remain the only event evidence. or combat evidence. Current transcript units remain the only event evidence.
Consumers receive a separate names-only projection in normalized registry
order, for example `{"npcs":[{"name":"Mira Thorn"}]}`. The projection omits
IDs and evidence. Its digest covers the exact projected bytes and is used for
consumer-local checkpoint identity, while the full durable artifact digest
remains the manifest and generated-reference provenance identity. The unbound
projection is exactly `{"npcs":[]}` and also has a projection digest.

View File

@@ -18,7 +18,9 @@ The durable JSON Schema is owned by the D&D spell artifact codec. The
extractor's private LLM response schema is a separate transport contract: its extractor's private LLM response schema is a separate transport contract: its
source-reference objects omit `source_id`, which the extractor assigns while source-reference objects omit `source_id`, which the extractor assigns while
mapping the response to the canonical artifact. The LLM DTO and transport mapping the response to the canonical artifact. The LLM DTO and transport
schema are not part of this durable contract. schema are not part of this durable contract. The private schema owns required
fields, JSON types, object and array shapes, and unknown-field rejection;
deterministic validators own the durable artifact's semantic constraints.
The output contains canonical spell casts derived from transcript evidence. The output contains canonical spell casts derived from transcript evidence.
Source IDs are assigned from the input identity; source-unit ranges identify Source IDs are assigned from the input identity; source-unit ranges identify
@@ -45,12 +47,10 @@ Each spell cast contains exactly these required fields:
- `caster`: in-world character or creature casting the spell; - `caster`: in-world character or creature casting the spell;
- `spell`: spell name; - `spell`: spell name;
- `effect`: concise spell effect in the scene;
- `narrative_description`: short description of the spell cast in context;
- `source_refs`: transcript source references with extractor-assigned source - `source_refs`: transcript source references with extractor-assigned source
IDs and evidence unit ranges. It must contain at least one entry. IDs and evidence unit ranges. It must contain at least one entry.
All four string fields must be non-empty. `caster` is the canonical in-world Both string fields must be non-empty. `caster` is the canonical in-world
caster, not the human player, transcript speaker, or GM when the associated caster, not the human player, transcript speaker, or GM when the associated
character or creature can be identified. Player and party references may character or creature can be identified. Player and party references may
disambiguate that identity, but do not independently establish that a cast disambiguate that identity, but do not independently establish that a cast
@@ -58,16 +58,10 @@ occurred. The `spell` value must resolve through the effective SRD-plus-overlay
catalog as either a canonical name or alias. Catalog validation accepts aliases catalog as either a canonical name or alias. Catalog validation accepts aliases
but does not rewrite them; unknown fields are rejected. but does not rewrite them; unknown fields are rejected.
`effect` and `narrative_description` record the casting declaration and its The artifact includes an actual casting event or an unambiguous declared
immediate resolution as established by the transcript. They do not follow casting attempt. Spell mentions, hypothetical plans, rules discussion, and
summoned creatures, persistent spell effects, or other downstream consequences catalog matches without a casting event are excluded. The spell catalog is a
through the rest of the scene. They also do not correct the table from name-recognition policy and never evidence that a cast occurred.
published D&D rules or supplement the transcript with model knowledge. When the
transcript contains a nonstandard or disputed ruling, the artifact may preserve
the immediate observed resolution and attribute relevant reasoning to the GM or
table; it must not present that reasoning as a universal game rule. The spell
catalog is name-recognition policy, not evidence for spell mechanics or
outcomes.
## Source References ## Source References
@@ -77,17 +71,11 @@ The unit IDs must be positive integers present in the input, and the start unit
must not appear after the end unit. Unknown fields are rejected. must not appear after the end unit. Unknown fields are rejected.
For each cast, the complete `source_refs` collection identifies the transcript For each cast, the complete `source_refs` collection identifies the transcript
evidence for every factual claim in `caster`, `spell`, `effect`, and evidence for the caster, spell name, and occurrence of the cast or declared
`narrative_description`. A cast declaration and its immediate resolution may be attempt. The deterministic validators establish that ranges are structurally
cited with separate narrow ranges when intervening units are unrelated. A valid and that the spell name is related to cited text. Semantic evidence
reported target, roll, amount, condition, interruption, or immediate outcome sufficiency is an extraction policy and remains subject to evaluation rather
must be supported by the cited units; otherwise the artifact describes only the than deterministic proof.
supported attempt or declaration. Later behavior by summoned creatures,
recurring effects, and other downstream consequences are outside the cast
artifact's evidence scope. The deterministic validators establish that ranges
are structurally valid and that the spell name is related to cited text.
Semantic claim completeness is an extraction policy and remains subject to
evaluation rather than deterministic proof.
Reference slot keys and accepted file types are defined in Reference slot keys and accepted file types are defined in
[Configuration](../config.md#implemented-production-modules). References are [Configuration](../config.md#implemented-production-modules). References are
@@ -100,17 +88,18 @@ The `dnd/spells` extractor accepts an optional `npcs` reference containing one
normalized NPC artifact as `application/json`, up to 1 MiB. An external file is normalized NPC artifact as `application/json`, up to 1 MiB. An external file is
validated during preparation; an ordered generated binding is validated at the validated during preparation; an ordered generated binding is validated at the
step handoff. Both paths use the approved NPC codec and identity policy, step handoff. Both paths use the approved NPC codec and identity policy,
re-encode canonical durable JSON, and supply that JSON as an operation-time re-encode canonical durable JSON for registry provenance, and supply only the
spell prompt input. It helps the model prefer canonical caster names and registry's ordered names as the operation-time spell prompt input. It helps the
recognize aliases; it does not establish that a spell was cast. model prefer canonical caster names; it does not establish that a spell was
cast.
NPC source references may identify the run that produced the registry or any NPC source references may identify the run that produced the registry or any
other session. They remain registry provenance and are never copied into a other session. They remain registry provenance and are never copied into a
spell cast's `source_refs`; every spell evidence range must still identify the spell cast's `source_refs`; every spell evidence range must still identify the
current transcript. Generated provenance records producer and canonical current transcript. Generated provenance records producer and canonical
artifact identity without payload content or a path. When the slot is absent, artifact identity without payload content or a path. When the slot is absent,
the prompt receives exactly `{"npcs":[]}` and the run has no NPC reference the prompt receives exactly `{"npcs":[]}` with its projection digest, and the
provenance or NPC checkpoint fingerprint. run has no NPC reference provenance.
## Normalization Behavior ## Normalization Behavior
@@ -128,10 +117,9 @@ does not synthesize references or change their boundaries.
After those per-cast changes, duplicate identity requires the same canonical After those per-cast changes, duplicate identity requires the same canonical
spell name, the same caster after case folding and whitespace normalization, spell name, the same caster after case folding and whitespace normalization,
and the same complete, non-empty set of source references valid for the source and the same complete, non-empty set of source references valid for the source
document. Only the first occurrence is retained, in stable order. Its caster, document. Only the first occurrence is retained, in stable order. Its caster
effect, narrative description, and canonical references are preserved without and canonical references are preserved. Unknown names, empty or invalid
prose merging or source union. Unknown names, empty or invalid evidence, and evidence, and casts with different evidence remain separate.
casts with different evidence remain separate.
Mutation and duplicate decisions are returned through the normal warnings Mutation and duplicate decisions are returned through the normal warnings
surface. Warning scopes use the merged input index, such as `spell_casts[0]`, surface. Warning scopes use the merged input index, such as `spell_casts[0]`,
@@ -152,6 +140,11 @@ chain, the catalog validator rejects the candidate with `unknown_spell`; the
explicit validator override that accepts the candidate promotes the unresolved explicit validator override that accepts the candidate promotes the unresolved
warning normally. warning normally.
The default extraction and normalization chains both preserve this registered
order: JSON syntax, spell shape, catalog membership, source references, JSON
Schema, then source relatedness. Extraction validates the private response
schema; normalization validates the durable artifact schema.
## Manifest Metadata ## Manifest Metadata
The extractor adds prompt and response-schema provenance under the artifact lane The extractor adds prompt and response-schema provenance under the artifact lane
@@ -194,7 +187,7 @@ provenance; see the [JSON output contract](json-output.md#manifestjson).
The `npc_registry_digest` and `npc_count` fields in the example are present for The `npc_registry_digest` and `npc_count` fields in the example are present for
an external NPC registry when the extractor publishes its prepared module an external NPC registry when the extractor publishes its prepared module
metadata. They contain no NPC names, aliases, source references, paths, or raw metadata. They contain no NPC names, source references, paths, or raw
bytes. A generated registry's identity is instead represented by the framework bytes. A generated registry's identity is instead represented by the framework
handoff provenance and dependency fingerprint, so the consumer module metadata handoff provenance and dependency fingerprint, so the consumer module metadata
does not duplicate it. does not duplicate it.

View File

@@ -134,9 +134,9 @@ reusable content follows it.
Accordingly, the common prefix of all three extraction prompts is system, Accordingly, the common prefix of all three extraction prompts is system,
extraction evidence, identity, and campaign references. The NPC prompt then extraction evidence, identity, and campaign references. The NPC prompt then
renders task, instructions, and transcript. Spell renders immediate resolution, renders task, instructions, and transcript. Spell renders the NPC registry,
NPC registry, catalog, task, instructions, and transcript. Combat renders catalog, task, instructions, and transcript. Combat renders the NPC registry,
immediate resolution, NPC registry, task, instructions, and transcript. The task, instructions, and transcript. The
scene chunker is not an extraction lane: it retains its separate system, scene chunker is not an extraction lane: it retains its separate system,
transcript, campaign-reference, task, and instruction order and marks its transcript, campaign-reference, task, and instruction order and marks its
transcript and campaign-reference messages ephemeral. transcript and campaign-reference messages ephemeral.
@@ -157,11 +157,11 @@ schemas remain package-owned.
The spell, NPC, and combat extractors' package-owned prompts declare their The spell, NPC, and combat extractors' package-owned prompts declare their
structured JSON inputs and private response schemas. Each private response structured JSON inputs and private response schemas. Each private response
schema remains separate from its durable artifact codec schema; this work does schema remains separate from its durable artifact codec schema; this work does
not use shared schema fragments or schema generation. The combat private schema not use shared schema fragments or schema generation. Those private schemas own
owns the transport envelope—required fields, JSON types, nullability, and the transport envelope—required fields, JSON types, nullability, and
unknown-field rejection—while its deterministic validators own semantic unknown-field rejection—while deterministic validators own semantic constraints
constraints such as enum membership, non-empty values and collections, and such as enum membership, non-empty values and collections, and positive
positive numbers. The spell extractor's prompt declares a required numbers. The spell extractor's prompt declares a required
`application/json` `spell_catalog` input and an optional `application/json` `application/json` `spell_catalog` input and an optional `application/json`
`npcs` input. The extractor generates `npcs` input. The extractor generates
the catalog input from its prepared the catalog input from its prepared
@@ -170,13 +170,13 @@ The shared D&D prompt assets include a generic NPC grounding fragment directly
after the campaign reference message for spell and combat prompts. When an NPC after the campaign reference message for spell and combat prompts. When an NPC
registry is bound, the registry is bound, the
domain registry boundary strictly decodes and identity-validates one durable domain registry boundary strictly decodes and identity-validates one durable
artifact, re-encodes canonical JSON, and generates a semantic digest over artifact, re-encodes canonical JSON for provenance, and separately generates a
those bytes. The unbound input is exactly `{"npcs":[]}`. Input digests cover names-only prompt projection. The unbound projection is exactly `{"npcs":[]}`.
the generated bytes; manifests record catalog identity and optional NPC Prompt input and component-local checkpoint digests cover the projected bytes;
registry digest/count rather than names, aliases, overlay bytes, registry manifests retain the optional full registry digest/count rather than names,
paths, or source metadata. Combat prompt, response-schema, mapping, overlay bytes, registry paths, or source metadata. Combat prompt,
normalization, identity, and bound-registry fingerprints remain separate response-schema, mapping, normalization, identity, and registry-projection
semantic inputs to checkpoint identity. fingerprints remain separate semantic inputs to checkpoint identity.
## Debug And Redaction Boundaries ## Debug And Redaction Boundaries

View File

@@ -29,6 +29,12 @@ variants. The D&D production registrar registers the canonical typed spell,
NPC, and combat implementations, including their kind-specific merge and NPC, and combat implementations, including their kind-specific merge and
normalize behavior. normalize behavior.
For D&D artifact defaults, generic JSON syntax validation runs first. Rejecting
domain validators then own semantic diagnostics before generic JSON Schema
validation provides the final rejecting representation backstop; warning-only
relatedness validators run last. This default composition does not reorder an
explicitly configured validator chain.
Prepared extractors, extract validators, and codecs may be reused concurrently Prepared extractors, extract validators, and codecs may be reused concurrently
by the run-wide extract pool. Production implementations are immutable after by the run-wide extract pool. Production implementations are immutable after
construction: they retain only typed options, immutable assets, or the shared construction: they retain only typed options, immutable assets, or the shared
@@ -103,8 +109,8 @@ The NPC identity package owns Unicode comparison keys, deterministic
`npc:sha256:` IDs, display normalization, and whole-registry collision issues. `npc:sha256:` IDs, display normalization, and whole-registry collision issues.
The registry package resolves one optional normalized artifact through the The registry package resolves one optional normalized artifact through the
strict codec, validates whole-registry identity, canonicalizes its JSON, and strict codec, validates whole-registry identity, canonicalizes its JSON, and
provides immutable records, prompt input, semantic digest, count, and exact provides immutable records, a names-only prompt projection, distinct durable
canonical-name/alias lookup. External files cross this boundary during and projection digests, count, and exact canonical-name lookup. External files cross this boundary during
preparation; generated artifacts cross it at the ordered step handoff. It owns preparation; generated artifacts cross it at the ordered step handoff. It owns
the `npcs` slot and its bounded, content-safe validation failures. NPC source the `npcs` slot and its bounded, content-safe validation failures. NPC source
references are durable provenance and are not treated as evidence for a references are durable provenance and are not treated as evidence for a
@@ -186,6 +192,12 @@ decodes the model response, assigns the generic source identity to every source
reference, canonicalizes duplicate references, orders spell casts by their reference, canonicalizes duplicate references, orders spell casts by their
earliest cited unit, and returns `dnd.SpellList`. earliest cited unit, and returns `dnd.SpellList`.
Its private response schema admits only the structural transport envelope:
required fields, JSON types, array and object shapes, and unknown-field
rejection. It maps integer source-unit candidates directly without repairing
semantic values, so the deterministic shape, catalog, and source-reference
validators own blank values, empty evidence, and invalid or unresolved ranges.
The extractor owns its private model-response DTO, embedded prompt, LLM response The extractor owns its private model-response DTO, embedded prompt, LLM response
schema, strict option decoder, injected shared LLM client, and prompt/schema schema, strict option decoder, injected shared LLM client, and prompt/schema
manifest metadata. During preparation it resolves the optional `spell_catalog` manifest metadata. During preparation it resolves the optional `spell_catalog`
@@ -195,16 +207,13 @@ failures therefore stop construction before source parsing or an LLM call;
campaign references remain separate disambiguation inputs and never become campaign references remain separate disambiguation inputs and never become
source evidence. source evidence.
The prompt limits each cast to its declaration and immediate resolution; it The prompt includes only actual casting events and unambiguous declared casting
does not follow summoned creatures, persistent effects, or other downstream attempts. Spell mentions, plans, rules discussion, and catalog matches without
consequences through the scene. Shared extraction-evidence and identity rules a casting event are excluded. Shared extraction-evidence and identity rules
require transcript-supported factual claims and the most specific in-world require transcript-supported caster and spell facts, while the catalog,
caster identity, while campaign references only disambiguate source text. campaign references, and NPC names only disambiguate source text. Structural
Effects describe the session as played: model rules knowledge cannot supplement source validation remains deterministic; semantic evidence sufficiency is
or correct the transcript, and nonstandard adjudication is attributed to the GM enforced through extraction policy and evaluation.
or table rather than stated as a universal rule. Structural source validation
remains deterministic; semantic claim completeness is enforced through
extraction policy and evaluation.
Both the extractor and deterministic catalog validator expose Both the extractor and deterministic catalog validator expose
the effective base-plus-overlay semantic digest as scoped prepared-component the effective base-plus-overlay semantic digest as scoped prepared-component
@@ -212,22 +221,23 @@ checkpoint identity. Raw overlay provenance independently covers file-byte
changes, while the semantic digest also invalidates reuse when the embedded changes, while the semantic digest also invalidates reuse when the embedded
catalog or catalog composition changes. The extractor additionally fingerprints catalog or catalog composition changes. The extractor additionally fingerprints
its complete prompt assets and private response schema, so either semantic its complete prompt assets and private response schema, so either semantic
contract changing invalidates previously recorded extraction checkpoints. The separate contract changing invalidates previously recorded extraction checkpoints. The
`internal/modules/dnd/codec/spells` package separate `internal/modules/dnd/codec/spells` package
owns the durable schema and stable JSON representation for artifact kind owns the durable schema and stable JSON representation for artifact kind
`dnd/spell-list`. The runner keeps the result typed through validators and later `dnd/spell-list`. The runner keeps the result typed through validators and later
stages, using the codec only for checkpoint, debug, and output boundaries. stages, using the codec only for checkpoint, debug, and output boundaries.
Shared D&D helpers keep prompt input Shared D&D helpers keep prompt input names and source-unit reference conversion
names and source-unit reference conversion consistent with the scene chunker. consistent with the scene chunker.
The extractor also declares the optional `npcs` registry slot and consumes the The extractor also declares the optional `npcs` registry slot and consumes the
immutable registry boundary from `internal/modules/dnd/npcs/registry`. An immutable registry boundary from `internal/modules/dnd/npcs/registry`. An
external registry is prepared before execution; a generated registry is external registry is prepared before execution; a generated registry is
validated and supplied at operation time. External bindings may add only validated and supplied at operation time. Bound external registries add only
`npc_registry_digest` and `npc_count` to module metadata and an the full `npc_registry_digest` and `npc_count` to module metadata. The local
`npc_registry` checkpoint fingerprint. Generated bindings are represented by `npc_registry` checkpoint fingerprint always covers the names-only projection,
including its exact unbound value. Generated bindings are represented by
framework handoff provenance and dependency fingerprints. The unbound prompt framework handoff provenance and dependency fingerprints. The unbound prompt
input is exactly `{"npcs":[]}` and has no registry provenance or fingerprint. input is exactly `{"npcs":[]}` and has no registry provenance.
The shared NPC grounding fragment is placed immediately after the common The shared NPC grounding fragment is placed immediately after the common
campaign reference message and is included in the spell prompt fingerprint. campaign reference message and is included in the spell prompt fingerprint.
@@ -241,14 +251,22 @@ assigns source identity and deterministic NPC IDs, and preserves source
references for deterministic validation. It uses the shared campaign references for deterministic validation. It uses the shared campaign
references only for disambiguation and does not consume the optional NPC references only for disambiguation and does not consume the optional NPC
registry slot. Its prompt and private response schema are package-owned. The registry slot. Its prompt and private response schema are package-owned. The
private response contains only a name and model-facing evidence ranges for each
record; anonymous groups, generic roles, invented labels, descriptions,
aliases, and relationships are outside its contract. The
prompt follows the shared D&D extraction ordering and cache policy documented prompt follows the shared D&D extraction ordering and cache policy documented
in [LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries). in [LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries).
The private response schema owns only structural transport validation and maps
integer source-unit candidates unchanged. Required semantic content, non-empty
evidence, and valid source ranges are rejected by the deterministic shape and
source-reference validators.
### `internal/modules/dnd/extract/combatturns` ### `internal/modules/dnd/extract/combatturns`
The combat extractor prepares one structured request per supplied chunk using The combat extractor prepares one structured request per supplied chunk using
the shared extraction-evidence, identity, campaign-reference, the shared extraction-evidence, identity, campaign-reference, NPC-grounding,
immediate-resolution, NPC-grounding, and transcript prompt inputs. It and transcript prompt inputs. It
maps the private response to `dnd.CombatTurnList`, assigns the current source maps the private response to `dnd.CombatTurnList`, assigns the current source
identity, removes exact duplicate source ranges, and orders turns by valid identity, removes exact duplicate source ranges, and orders turns by valid
source-document position while preserving malformed candidate fields for source-document position while preserving malformed candidate fields for
@@ -269,11 +287,10 @@ for deterministic normalization.
### `internal/modules/dnd/normalize/npcs` ### `internal/modules/dnd/normalize/npcs`
The NPC normalizer performs deterministic identity-aware consolidation in The NPC normalizer performs deterministic identity-aware consolidation in
merged input order. It unions only canonical identity or canonical/alias merged input order. It consolidates only equal canonical-name comparison keys,
matches, retains the first display record, unions exact relationships and retains the first display record, and unions exact source references. It exposes
source references, rewrites unambiguous relationship targets, and leaves the identity policy as its local checkpoint fingerprint and emits bounded
ambiguous collisions for identity validation. It exposes the identity policy normalization warnings.
as its local checkpoint fingerprint and emits bounded normalization warnings.
## Merger And Normalizer ## Merger And Normalizer
@@ -301,9 +318,9 @@ scoped warnings for each mutation or unresolved name.
After those per-cast changes, it collapses only casts with the same canonical After those per-cast changes, it collapses only casts with the same canonical
spell, case-folded and whitespace-normalized caster, and complete non-empty spell, case-folded and whitespace-normalized caster, and complete non-empty
valid source-reference set. It retains the first occurrence and its caster, valid source-reference set. It retains the first occurrence and its caster,
effect, narrative description, and stable order. Unknown names, empty or source references, and stable order. Unknown names, empty or invalid evidence,
invalid evidence, and adjacent or overlapping but different ranges remain and adjacent or overlapping but different ranges remain unchanged for
unchanged for validation. validation.
The normalizer exposes the effective catalog digest as its independently scoped The normalizer exposes the effective catalog digest as its independently scoped
`effective_catalog` checkpoint fingerprint and reports catalog base ID, digest, `effective_catalog` checkpoint fingerprint and reports catalog base ID, digest,
@@ -316,13 +333,13 @@ independently for extraction and normalization.
The combat normalizer prepares an external NPC registry before execution or The combat normalizer prepares an external NPC registry before execution or
receives a generated registry at the ordered step handoff, then uses the receives a generated registry at the ordered step handoff, then uses the
immutable view during runtime. It display-normalizes combat fields, immutable view during runtime. It display-normalizes actors,
rewrites exact canonical-name or alias matches for actors and targets, orders rewrites canonical-name matches for actors, orders and deduplicates source
and deduplicates source references, stable-sorts records by source-document references, stable-sorts records by source-document position, and collapses
position, and collapses only exact duplicate identities with fully valid only exact duplicate identities with fully valid evidence. It deep-clones
evidence. It deep-clones output storage and emits bounded warnings scoped to output storage and emits bounded warnings scoped to merged input indexes. Its
merged input indexes. Its metadata and fingerprints identify the normalization metadata and fingerprints identify the normalization and NPC identity policies.
and NPC identity policies. External bindings may contribute registry External bindings may contribute registry
digest/count metadata; generated identity is retained in framework provenance digest/count metadata; generated identity is retained in framework provenance
and dependency fingerprints. The normalizer is included in the production D&D and dependency fingerprints. The normalizer is included in the production D&D
registrar with the default combat normalization chain. registrar with the default combat normalization chain.
@@ -358,11 +375,11 @@ codec bytes according to its target context. Neither validator calls the LLM.
## D&D Spell Validators ## D&D Spell Validators
All four validators receive `dnd.SpellList` directly. The shape validator All four validators receive `dnd.SpellList` directly. The shape validator
rejects missing or empty spell fields and empty reference lists. The catalog rejects a missing list, blank caster or spell names, and empty reference lists.
validator defers when shape is invalid, then checks every non-empty spell name The catalog validator defers when shape is invalid, then checks every non-empty
against the immutable effective SRD and overlay catalog. It accepts normalized spell name against the immutable effective SRD and overlay catalog. It accepts
canonical names and aliases without rewriting the artifact; unknown names normalized canonical names and aliases without rewriting the artifact; unknown
reject the complete result with bounded, stable index/name diagnostics. The names reject the complete result with bounded, stable index/name diagnostics. The
source-reference validator defers malformed shapes, validates every cited source-reference validator defers malformed shapes, validates every cited
range, and reports all range defects through a bounded aggregate while range, and reports all range defects through a bounded aggregate while
preserving `invalid_source_refs`. The relatedness validator resolves all cited preserving `invalid_source_refs`. The relatedness validator resolves all cited
@@ -383,34 +400,33 @@ payload rules are defined in the
## D&D NPC Validators ## D&D NPC Validators
NPC shape validation checks required strings, arrays, and source-reference NPC shape validation checks the required ID and name strings, list presence, and source-reference
shape. The source-reference validator defers malformed shapes, checks shape. The source-reference validator defers malformed shapes, checks
current-document identity, unit existence, and range ordering, and reports all current-document identity, unit existence, and range ordering, and reports all
defects through bounded aggregates. Source relatedness uses the shared defects through bounded aggregates. Source relatedness uses the shared
document-order traversal and normalized consecutive-token matching, emitting at document-order traversal and normalized consecutive-token matching, emitting at
most one bounded warning per record when neither the canonical name nor an most one bounded warning per record when the canonical name does not occur near
alias occurs near its cited text. Invalid shape or cited ranges produce no its cited text. Invalid shape or cited ranges produce no relatedness warnings.
relatedness warnings. Normalize identity validation checks deterministic IDs, Normalize identity validation checks deterministic IDs, canonical names, and
canonical names, aliases, and cross-record ownership or canonical collisions. duplicate canonical-name or ID ownership.
All are deterministic and expose the policy fingerprints used by the All are deterministic and expose the policy fingerprints used by the
production chains. production chains.
## D&D Combat Validators ## D&D Combat Validators
Combat shape validation owns required arrays, strings, nullable values, positive Combat shape validation owns the required list, actor, supported turn kind, and
rounds, and supported enums. Combat source-reference validation defers invalid non-empty source-reference collection. Combat source-reference validation defers invalid
shape, checks source identity, unit existence, and range order, and reports all shape, checks source identity, unit existence, and range order, and reports all
defects through bounded aggregates. Combat source-relatedness defers invalid defects through bounded aggregates. Combat source-relatedness defers invalid
shape or ranges, uses the shared traversal to combine overlapping cited units shape or ranges, uses the shared traversal to combine overlapping cited units
in document order, and emits at most one bounded advisory warning per turn for in document order, and emits at most one bounded advisory warning per turn for
unrelated actors or declaration text. an unrelated actor. Actors use normalized consecutive-token matching. The
Actors use normalized consecutive-token matching; declarations retain the normalized-invariants validator owns actor display normalization, canonical
minimum four-rune token heuristic. The normalized-invariants
validator owns display normalization, comparison-unique targets, canonical
source-reference order, chronology, and exact duplicate identity; it defers source-reference order, chronology, and exact duplicate identity; it defers
shape and source-reference failures. All four validators are deterministic and shape and source-reference failures. All four validators are deterministic and
expose local policy fingerprints. The D&D registrar orders them after generic expose local policy fingerprints. In the registered defaults, JSON syntax runs
JSON and response-schema validation at extraction and normalization. first; combat shape, normalized invariants when applicable, and source-reference
validation precede JSON Schema validation; warning-only relatedness runs last.
## Production Registration ## Production Registration

View File

@@ -87,7 +87,7 @@ Configuration. The implemented module packages are:
| `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. | | `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. |
| `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. | | `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. |
| `internal/modules/dnd/chunk/scenes` | Produces contiguous D&D scene chunks from structured model output. | | `internal/modules/dnd/chunk/scenes` | Produces contiguous D&D scene chunks from structured model output. |
| `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, relationship, combat-turn-list, combat-turn, and combat-action artifact types. | | `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, combat-turn-list, and combat-turn artifact types. |
| `internal/modules/dnd/codec/spells` | Strictly decodes and stably encodes the durable D&D spell-list representation. | | `internal/modules/dnd/codec/spells` | Strictly decodes and stably encodes the durable D&D spell-list representation. |
| `internal/modules/dnd/codec/npcs` | Strictly decodes and stably encodes the durable D&D NPC-list representation. | | `internal/modules/dnd/codec/npcs` | Strictly decodes and stably encodes the durable D&D NPC-list representation. |
| `internal/modules/dnd/codec/combatturns` | Strictly decodes and stably encodes the durable D&D combat-turn-list representation. | | `internal/modules/dnd/codec/combatturns` | Strictly decodes and stably encodes the durable D&D combat-turn-list representation. |
@@ -102,7 +102,7 @@ Configuration. The implemented module packages are:
| `internal/modules/generic/merge/appendorder` | Combines accepted extraction results in chunk order. | | `internal/modules/generic/merge/appendorder` | Combines accepted extraction results in chunk order. |
| `internal/modules/generic/normalize/noop` | Preserves accepted merged output. | | `internal/modules/generic/normalize/noop` | Preserves accepted merged output. |
| `internal/modules/dnd/normalize/spells` | Canonicalizes catalog-backed spell names and exact source references, conservatively collapses duplicate casts, and reports deterministic warnings and independently scoped catalog checkpoint identity. | | `internal/modules/dnd/normalize/spells` | Canonicalizes catalog-backed spell names and exact source references, conservatively collapses duplicate casts, and reports deterministic warnings and independently scoped catalog checkpoint identity. |
| `internal/modules/dnd/normalize/npcs` | Consolidates NPC records deterministically by identity and aliases, rewrites unambiguous relationship targets, and reports bounded warnings. | | `internal/modules/dnd/normalize/npcs` | Consolidates NPC records deterministically by canonical name, unions exact evidence, and reports bounded warnings. |
| `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, and rejections as logical JSON files. | | `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, and rejections as logical JSON files. |
`internal/modules/dnd/shared` owns reusable D&D prompt fragments, `internal/modules/dnd/shared` owns reusable D&D prompt fragments,
@@ -115,11 +115,13 @@ this package. Domain-neutral prompt filesystem composition lives in
The `dnd/npcs/registry` package owns the optional `npcs` registry boundary. The `dnd/npcs/registry` package owns the optional `npcs` registry boundary.
External references are strictly decoded and identity-validated during External references are strictly decoded and identity-validated during
preparation; generated references are decoded and identity-validated at the preparation; generated references are decoded and identity-validated at the
ordered step handoff. Both paths emit canonical registry JSON to operation-time ordered step handoff. Both paths retain canonical registry JSON for provenance
spell and combat prompt or normalization requests. The framework records and emit a names-only projection to operation-time spell and combat prompts.
generated identity and bounded producer provenance, while the raw external Combat normalization uses the canonical registry for exact actor lookup. The
reference remains independently tracked by pipeline provenance. An absent framework records generated identity and bounded producer provenance, while
registry is represented only by the empty prompt value `{"npcs":[]}`. Spell the raw external reference remains independently tracked by pipeline
provenance. An absent registry is represented only by the empty prompt value
`{"npcs":[]}`. Spell
and combat consumers use this shared boundary without changing their public and combat consumers use this shared boundary without changing their public
module contracts. module contracts.

View File

@@ -193,10 +193,12 @@ the runner returns.
The pipeline-wide coordinator owns the ordered step loop, generated-reference The pipeline-wide coordinator owns the ordered step loop, generated-reference
sets at each barrier, and deterministic merging of step outcomes. For one step, sets at each barrier, and deterministic merging of step outcomes. For one step,
the lane engine initializes checkpoint state in lane order, dispatches bounded one run-local lane engine owns worker lifecycle, cancellation, dispatch,
extract work, advances terminal lanes through serial merge and normalize work, continuation queues, and result collection. It initializes checkpoint state in
selects failures by stable pipeline scope, and merges lane-local outcomes back lane order, dispatches bounded extract work, advances terminal lanes through
in resolved order. Completion timing never becomes public ordering. serial merge and normalize work, selects failures by stable pipeline scope, and
merges lane-local outcomes back in resolved order. Completion timing never
becomes public ordering.
The runner: The runner:
@@ -296,6 +298,13 @@ canonical chunk JSON or artifact codec bytes. Validators execute in resolved
order and stop at the first error or rejection. An empty chain approves the order and stop at the first error or rejection. An empty chain approves the
result. result.
Production D&D artifact chains keep generic JSON syntax validation first, then
run every rejecting domain validator before generic JSON Schema validation. The
domain validator therefore owns expected semantic diagnostics; the generic
schema validator remains the final rejecting representation backstop, before
warning-only relatedness validation. Explicitly configured validator chains
retain their configured order.
`runWithRetry` applies the effective retry policy around module execution and `runWithRetry` applies the effective retry policy around module execution and
its complete validation chain. A module or validator error becomes a framework its complete validation chain. A module or validator error becomes a framework
error when attempts are exhausted. A rejection becomes a recorded error when attempts are exhausted. A rejection becomes a recorded
@@ -329,14 +338,16 @@ current non-empty checkpoint identity to match, so the invocation identity
still binds the input, resolved topology and configuration, references, runtime still binds the input, resolved topology and configuration, references, runtime
overrides, profiles, and component fingerprints. overrides, profiles, and component fingerprints.
The runner decodes that accepted normalized artifact with the prepared codec, The runner decodes and canonically re-encodes each reusable artifact once with
re-encodes it, and requires exact kind, schema identity and digest, media type, the prepared codec, requiring exact kind, schema identity and digest, media
canonical bytes, content digest, and producer provenance. A valid result becomes type, canonical bytes, content digest, and producer provenance. A valid accepted
a runner-owned cloned normalized output, restores only normalize-checkpoint producer becomes a runner-owned cloned normalized output, restores only
warnings, and records one `accepted_artifact_reused` normalize decision. It does normalize-checkpoint warnings, and records one `accepted_artifact_reused`
not invoke or record extract, merge, normalize, or their validators. Invalid or normalize decision. It does not invoke or record extract, merge, normalize, or
unavailable accepted state records its decision and fails the producer step; their validators. Invalid or unavailable accepted state records its decision
the dependent step never starts and the producer is not implicitly rerun. and fails the producer step; the dependent step never starts and the producer
is not implicitly rerun. If a later required lane fails during initialization,
already hydrated terminal lanes remain in the failed output in resolved order.
Generated references add downstream dependencies containing the producer's Generated references add downstream dependencies containing the producer's
artifact kind, complete schema identity, media type, canonical content digest, artifact kind, complete schema identity, media type, canonical content digest,

View File

@@ -57,14 +57,14 @@ records the decision, and stops without executing the producer or consumer.
The loader assigns a typed category and reason code at each validation site; The loader assigns a typed category and reason code at each validation site;
diagnostic prose is not classified after the fact. The runner then applies diagnostic prose is not classified after the fact. The runner then applies
forced-execution policy, validates reusable artifact bytes through the prepared forced-execution policy, validates reusable artifact bytes through the prepared
codec, and records the final decision before enforcing a required-predecessor codec once, returns the canonical hydrated value to the stage, and records the
failure. That failure names only the step, lane, and stable reason code. Decision final decision before enforcing a required-predecessor failure. That failure
detail passes through one UTF-8-safe bounded sanitizer and contains only names only the step, lane, and stable reason code. Decision detail is selected
allowlisted diagnostic context, never payloads, references, credentials, from code-owned descriptions by reason code and then UTF-8 normalized and
environment values, or physical paths. Typed categories and codes remain intact bounded; callers cannot supply arbitrary diagnostic prose. Typed categories and
through pipeline events and become strings only in manifest and debug-summary codes remain intact through pipeline events and become strings only in manifest
JSON. [Operations](../operations.md#resume-and-selective-recompute) is the and debug-summary JSON. [Operations](../operations.md#resume-and-selective-recompute)
canonical operator-facing reason-code reference. is the canonical operator-facing reason-code reference.
`internal/core/fileio` provides confined atomic file writes used by state `internal/core/fileio` provides confined atomic file writes used by state
collaborators. The chunk-plan store retains its stronger entry validation. collaborators. The chunk-plan store retains its stronger entry validation.

View File

@@ -55,7 +55,7 @@ go run ./cmd/notarius run dnd-npc-grounded \
--output-dir ./npc-grounded-output --output-dir ./npc-grounded-output
``` ```
The NPC artifact grounds canonical names and aliases, not spell or combat The NPC artifact grounds canonical names through a names-only prompt projection, not spell or combat
evidence. Current-transcript source ranges remain the only event evidence. The evidence. Current-transcript source ranges remain the only event evidence. The
manifest records generated-reference identity and bounded producer provenance; manifest records generated-reference identity and bounded producer provenance;
it does not record generated payload content, and no generated content is it does not record generated payload content, and no generated content is
@@ -209,9 +209,10 @@ Checkpoint reason codes are stable diagnostic identifiers:
| `accepted_artifact_reused` | A required producer's accepted normalized artifact was canonically validated and hydrated. | | `accepted_artifact_reused` | A required producer's accepted normalized artifact was canonically validated and hydrated. |
| `recompute_step` | Selective recomputation forced execution of this lane. | | `recompute_step` | Selective recomputation forced execution of this lane. |
Decision detail is bounded explanatory text, not a data-recovery channel. It Decision detail is bounded explanatory text derived from the stable reason code,
never contains checkpoint paths, artifact or reference content, source content, not caller-supplied prose or a data-recovery channel. It never contains
credentials, or environment values. checkpoint paths, artifact or reference content, source content, credentials,
or environment values.
## Debug Bundles ## Debug Bundles

View File

@@ -1,174 +1,373 @@
# D&D Validation-Boundary Alignment Implementation Plan # Minimal D&D Extraction Contracts Implementation Plan
Status: Proposed. **Status:** Implemented
Implement this plan in order. It repairs the immediate combat extraction ## Objective
failure, makes D&D validation diagnostics consistently domain-owned, and then
aligns the spell and NPC private response boundaries with the same policy.
Do not change durable artifact schemas, artifact kinds, public Go types, Implement the durable contract redesign defined by
framework retry behavior, checkpoint formats, or the scene chunker. Existing [Minimal D&D Extraction Contracts](minimal-dnd-extraction-contracts.md) and
v1 private-schema identities may be corrected in place because Notarius has not [ADR-0009](../adr/0009-minimal-evidence-grounded-extraction-artifacts.md).
been run in production. Changed prompt and schema content will invalidate The result is a coordinated in-place redesign of NPC, spell-cast, and combat-turn
development checkpoints through existing fingerprints. extraction. Each lane must emit only its narrow, evidence-grounded facts; no
removed rich-schema field may survive as an optional field, placeholder, compatibility
shim, or deterministic synthesis.
## Cross-Stage Decisions This plan deliberately starts with NPCs because the NPC registry is a generated
reference consumed by spell and combat extraction. Spell and combat then change
independently, followed by one repository-wide contract and documentation
pass.
Private LLM schemas own the transport envelope: required fields, JSON types, ## Fixed Decisions And Guardrails
nullability, array/object shape, and unknown-field rejection. Deterministic
domain validators own semantic rules: supported enum values, nonblank values,
required non-empty collections, positive and resolvable source units,
catalog/identity policy, and normalized invariants.
For typed D&D artifacts, `generic/valid_json` remains first as a representation The implementing agent must treat these as decisions, not open design choices:
sanity check. All deterministic domain validators that can reject a candidate
run next. `generic/valid_json_schema` runs after them as a durable-schema
backstop, followed by warning-only relatedness validators. This order gives
expected candidate failures bounded domain reason codes while retaining a final
check that typed encoding conforms to the durable contract.
Do not expose raw JSON Schema errors or candidate values through the generic - Keep the artifact kinds `dnd/npc-list`, `dnd/spell-list`, and
validator. Do not add tests that require particular words or phrases to remain `dnd/combat-turn-list`, their module keys and capabilities, their media types,
in prompt prose. and their prompt IDs stable.
- Retain exactly the existing v1 schema keys, IDs, names, versions, filenames,
prompt IDs, and prompt versions listed in the feature roadmap. Change their
unpublished shapes and content in place; do not harmonize the private spell
identity as part of this work.
- Do not add v2 assets, runtime version negotiation, migration code, dual-write
behavior, a second module registration, or compatibility fixtures for the
superseded pre-release shapes. Existing fixtures should be rewritten or
deleted according to whether they still protect current behavior.
- Require the top-level list field and every record field. A list may be empty,
but every returned record must have at least one source reference. Use strict
JSON objects with unknown fields rejected and no nullable or optional legacy
fields.
- Keep private source references limited to `start_unit_id` and `end_unit_id`.
Mapping assigns the current source document ID; campaign references and an
NPC registry never become event evidence.
- Keep the shared prompt ordering policy: stable shared instructions first,
stable campaign and generated references next, module task material after
those references, and the chunk-variable transcript last.
- Preserve the existing production validator-chain order and the rule that a
configured chain is authoritative. Remove or simplify validators; do not
silently reorder chains.
- Preserve the existing NPC ID derivation algorithm and comparison/display
normalization for canonical names. Retain existing semantic-policy
identifiers as well as schema and prompt versions; pre-redesign development
state is disposable. Unchanged canonical names must not receive new IDs.
- Use deterministic code only for display normalization, known catalog or NPC
canonicalization, application-owned IDs, evidence canonicalization, ordering,
and exact duplicate collapse. Do not introduce fuzzy matching or inferred
enrichment.
- Follow the testing policy: protect schemas and behavior at their owning
boundaries, delete obsolete tests, and avoid tests that snapshot prompt prose
or detect exact shared-prefix length. No live or paid model call is a stage
completion requirement.
- Each stage must leave the repository compiling and `go test ./...` passing.
Update all affected fixtures and callers within the stage that changes a
public Go type; do not leave an intentionally broken intermediate commit.
## Stage 1: Repair Combat Extraction ## Stage 1: Cut Over The NPC Contract And Name Projection
### Changes This stage establishes the redesigned generated reference on which later stages
depend.
- Update the combat extraction instructions to enumerate the complete allowed ### 1.1 Replace the durable and private data shapes
values:
- `turn_kind`: `turn`, `reaction`, `legendary_action`, `lair_action`,
`other`;
- action `category`: `attack`, `spell`, `movement`, `item`,
`ability_check`, `saving_throw`, `condition`, `other`.
- Keep the private combat schema structurally permissive and the durable schema
strict. Do not restore enum, minimum, `minLength`, or `minItems` constraints
to the private schema.
- Reorder the combat extraction default chain to:
`generic/valid_json`, combat shape, combat source references,
`generic/valid_json_schema`, combat source relatedness.
- Reorder the combat normalization default chain to:
`generic/valid_json`, combat shape, normalized invariants, combat source
references, `generic/valid_json_schema`, combat source relatedness.
- Preserve configured validator overrides as authoritative; only production
default composition changes.
### Tests - Reduce `dnd.NPC` in `internal/modules/dnd/types.go` to exactly `ID`, `Name`,
and `SourceRefs`. Delete `NPCRelationship` and every alias, description, and
relationship field or helper that becomes unused.
- Rewrite the existing strict durable schema `dnd_npcs.v1.json` with required
top-level `npcs`, and records containing only required `id`, `name`, and
`source_refs`. Keep the NPC codec at schema version `v1`, name
`notarius_dnd_npcs_v1`, and its existing schema ID and media type.
- Rewrite the strict private schema `dnd_npcs_llm.v1.json`. Its records contain
only required `name` and model-facing source ranges. Keep the extractor schema
identity and prompt ID/version unchanged.
- Reduce the private response DTO and mapping accordingly. Preserve candidate
data at the mapping boundary so deterministic validators, rather than mapping
defaults, reject blank names or invalid ranges. Assign the current source ID
and derive the NPC ID in application code.
- Rewrite the NPC task and instruction assets to ask only for individually
identifiable NPC names and supporting transcript ranges. Explicitly exclude
anonymous groups, generic roles, invented labels, descriptions, aliases, and
relationships. Keep transcript material last in the manifest.
- Add an assembled combat pipeline case whose raw LLM response contains an ### 1.2 Simplify NPC identity, normalization, and validation
unsupported turn kind or action category. Exhausted retries must produce a
non-fatal `invalid_combat_turn_shape` rejection owned by the combat shape
validator, not `json_schema_invalid` or a framework error.
- Retain coverage that a later valid retry succeeds and discarded-attempt
warnings/rejections do not become durable.
- Update registrar contract tests to assert the new extraction and
normalization order.
- Rely on behavioral enum/schema tests and prompt fingerprint coverage; do not
add prompt-word change-detector tests.
### Completion Check - Keep the identity policy at `dnd.npcs.identity.v1`. Retain display
normalization, comparison keys, ID syntax, and deterministic ID derivation.
Validate nonblank canonical names, exact ID/name agreement, duplicate
canonical names, and duplicate IDs. Delete alias validation and alias-specific
issue locations/codes.
- Keep NPC normalization at `dnd.npcs.normalize.v1`. Normalize the retained
display name, derive its ID, canonicalize and deduplicate source references,
consolidate records only by the canonical-name comparison key, preserve the
first stable record, and union exact evidence. Remove alias promotion,
relationship merge/rewrites, and their warning codes.
- Keep the existing NPC shape and source-relatedness policy identifiers. The
shape validator checks only list presence, nonblank `id` and `name`, and
nonempty source references; source-relatedness grounds a record only through
its retained name. The existing source-ref validator remains the owner of
range validity.
- Keep the default validation order domain-first, then JSON Schema, then the
advisory relatedness validator. Simplify only the validator implementations
and selections whose owned behavior changed.
Run `go test ./internal/modules/dnd/extract/combatturns ### 1.3 Separate durable registry provenance from model input
./internal/modules/dnd/validate/combatturns/... ./internal/modules/dnd/register
./internal/modules/integration` and `git diff --check`.
## Stage 2: Make D&D Validator Ordering Consistent - Continue to canonicalize and retain the complete redesigned NPC artifact for
registry validation, cache identity, manifests, and provenance.
`Registry.Digest()` remains the digest of that complete canonical artifact
when bound and remains empty when no registry was supplied.
- Build a second structural JSON projection for model grounding with the exact
shape `{"npcs":[{"name":"Mira Thorn"}]}` in normalized registry order. It
contains names only: no IDs, source references, origin URI, aliases, or other
provenance. Generate it with typed values and `json.Marshal`, not string
concatenation. The empty projection is exactly `{"npcs":[]}`.
- Add an explicit projection digest accessor. The digest is SHA-256 over the
exact projected bytes, including for an absent or empty registry, and
`PromptInput().Digest` must equal it. Keep full-artifact and projection digests
distinct even when their content happens to coincide.
- Index `Registry.Lookup` by canonical name only. Retain immutable return values
and defensive copies. Remove alias indexing and alias-aware comments.
- In spell extraction, combat extraction, and combat normalization, use the NPC
name-projection digest for the component-local checkpoint fingerprint because
it exactly describes the names that affect those operations. Keep the full
registry digest and count in manifest metadata for provenance. Framework-owned
generated-reference fingerprints may still invalidate a run when any upstream
artifact byte changes; do not broaden this stage into a framework cache
redesign.
### Changes ### 1.4 Update owners, consumers, and tests
- Move `generic/valid_json_schema` behind all rejecting domain validators in - Update NPC codec, schema, extractor, identity, validator, normalizer, registry,
every spell and NPC extraction and normalization default chain: registration, pipeline-integration, and CLI fixtures to the new shape. Adapt
- spell extraction/normalization: shape, catalog, source references, schema, spell and combat tests that construct `dnd.NPC` values so the repository
then source relatedness; remains buildable, but do not change their own artifact contracts yet.
- NPC extraction: shape, source references, schema, then source relatedness; - Add or rewrite focused tests for strict schema acceptance/rejection,
- NPC normalization: shape, identity, source references, schema, then source codec round trips, unchanged ID derivation for known names, name-only
relatedness. consolidation, evidence union, registry immutability, and canonical-name-only
- Keep `generic/valid_json` first and warning-only source relatedness last. lookup. Rewrite the existing fixture for the minimal current contract; do not
- Do not change validator implementations, reason codes, warning promotion, retain the superseded rich fixture solely to test backwards incompatibility.
retry counts, or user-provided chain order. - Test the projection as a data contract: it contains only ordered names,
- Document the default-chain policy in the pipeline/module internals: domain equivalent normalized registries produce identical bytes and digest,
validators diagnose expected semantic failures and the generic schema evidence/ID-only changes do not change the projection digest, and name/order
validator is the final rejecting representation backstop. changes do. This is not authorization to snapshot assembled prompt prose or
prefix lengths.
- Update `docs/integrations/dnd-npc-artifacts.md` to own the redesigned v1
durable schema. Update `docs/internal/modules.md` and
`docs/internal/llm.md` only for current NPC behavior that lands in this stage.
### Tests ### Stage 1 completion criteria
- Update production registrar tests for every affected chain. - NPC durable and private schemas expose no removed enrichment fields.
- Add one representative spell and NPC assembled rejection proving that a - The generated NPC prompt input contains names only while manifests retain
domain-invalid but encodable candidate is attributed to the owning domain full registry provenance.
validator rather than the generic schema validator. Do not duplicate each - Spell and combat consumers accept generated or external registries in the
validator package's existing case matrix at integration level. redesigned v1 shape; rich pre-redesign registry JSON fails strict decoding.
- Confirm explicitly configured validator chains retain their exact configured - Focused NPC, spell-wiring, combat-wiring, integration, and CLI tests pass, and
order. `go test ./...` passes.
### Completion Check ## Stage 2: Cut Over Spell-Cast Extraction
Run `go test ./internal/modules/dnd/register ./internal/modules/integration ### 2.1 Replace the spell contract and prompt
./internal/framework/pipeline` and `git diff --check`.
## Stage 3: Align Spell and NPC Private Response Boundaries - Reduce `dnd.SpellCast` to exactly `Caster`, `Spell`, and `SourceRefs`.
- Rewrite `dnd_spells.v1.json` in place. Keep the durable codec at version v1,
name `notarius_dnd_spells_v1`, and its existing schema ID. Its strict record
contains only required `caster`, `spell`, and `source_refs`.
- Rewrite `dnd_spells_llm.v1.json` with the same logical fields and model-facing
ranges. Keep the existing private key, ID, name, prompt version, and schema
path unchanged.
- Reduce the private DTO, canonicalization, and mapping to the retained fields.
Continue assigning current source IDs, stable-ordering candidates by evidence,
and preserving semantically invalid candidates for deterministic validation.
- Rewrite spell task and instruction assets around the narrow casting-event
boundary. Retain the spell catalog and name-only NPC projection as
disambiguation inputs, never evidence. Remove effect, outcome, and narrative
duties and remove the `common-dnd-immediate-resolution.md` message from the
spell manifest and spell asset registration. Do not delete the shared file in
this stage because combat still uses it.
### Changes ### 2.2 Simplify spell policies
- Revise the existing v1 spell and NPC private schemas in place: - Keep the spell shape policy identifier unchanged and validate only list
- retain required fields, JSON types, array/object structure, presence, nonblank caster/spell, and nonempty source references.
`additionalProperties: false`, and omission of framework-assigned fields; - Keep catalog validation, source-reference validation, and source-relatedness
- remove `minLength`, `minItems`, and positive-number `minimum` constraints; behavior and policy identities unchanged where their actual semantics are
- leave the durable spell and NPC schemas unchanged. already limited to spell name, caster, and evidence.
- Replace `shared.UnitRef` in the private spell and NPC response DTOs with - Simplify normalization and duplicate comparison to caster, catalog-canonical
integer candidates so zero and negative unit IDs survive decoding and mapping spell name, and the complete valid evidence set. Remove all prose selection,
into `source.SourceRef` for deterministic source validation. copying, fixture fields, and assertions. Preserve catalog fingerprints and
- Update canonicalization and ordering helpers to operate on candidate integers metadata.
without repairing invalid values. Valid positive IDs retain current output, - Keep the exact inclusion rule from the feature roadmap: an actual casting or
ordering, and exact-deduplication behavior; invalid ranges remain available unambiguous declared attempt is included; mentions, plans, rules discussion,
to validators. and catalog matches are not.
- Keep malformed JSON, missing/unknown fields, wrong JSON types, and
non-integer source IDs as LLM-boundary errors.
- Update LLM/module internals and the spell/NPC integration contracts to state
the structural-private/semantic-validator ownership boundary.
### Tests ### 2.3 Update owners and tests
- For each private schema, prove structurally valid candidates with blank - Update spell codec, private schema, extractor, validators, normalizer, merge
strings, empty required collections, and nonpositive unit IDs pass the and registration tests, pipeline integration, CLI output fixtures, and any
private schema, while missing fields, unknown fields, and wrong JSON types do maintained examples to the minimal shape. Rewrite or delete rich-schema
not. fixtures rather than retaining them as compatibility cases.
- Through raw-JSON LLM fakes, prove semantic values survive decoding and mapping - Replace tests of effects and narrative descriptions with focused tests of the
without repair. retained contract: strict unknown-field rejection, codec round trips, current
- Add representative assembled cases showing: source-ID assignment, catalog canonicalization, evidence ordering, duplicate
- blank or empty spell/NPC fields are rejected by the appropriate shape collapse, and NPC name-projection wiring.
validator; - Update `docs/integrations/dnd-spell-artifacts.md` as the canonical redesigned
- nonpositive or nonexistent unit IDs are rejected by the appropriate source v1 contract and update current internal module/LLM documentation for the
validator; and smaller prompt and response. Do not duplicate the spell catalog contract
- exhausted validation retries remain non-fatal rejected outputs. owned by its existing integration document.
- Preserve existing valid mapping, source-position ordering, deduplication,
catalog, identity, checkpoint-fingerprint, and durable codec tests.
### Completion Check ### Stage 2 completion criteria
Run `go test ./internal/modules/dnd/extract/spells - No production spell type, schema, prompt, validator, normalizer, fixture, or
./internal/modules/dnd/extract/npcs ./internal/modules/dnd/validate/spells/... documentation contract refers to effect or narrative description.
./internal/modules/dnd/validate/npcs/... ./internal/modules/integration` and - Spell prompt/schema identities remain exactly their existing v1 values.
`git diff --check`. - Focused spell and pipeline tests pass, and `go test ./...` passes.
## Final Verification ## Stage 3: Cut Over Combat-Turn Extraction
Run: ### 3.1 Replace the combat contract and prompt
```text - Reduce `dnd.CombatTurn` to exactly `Actor`, `TurnKind`, and `SourceRefs`.
git diff --check Delete `CombatAction`, `CombatActionCategory`, their constants, and all
now-unused helpers. Retain the existing five `CombatTurnKind` values.
- Rewrite `dnd_combat_turns.v1.json` in place. Keep the durable codec at version
v1, name `notarius_dnd_combat_turns_v1`, and its existing schema ID. Use a
strict required record with `actor`, `turn_kind`, and `source_refs` only.
- Simplify the durable codec to direct strict encoding/decoding if its custom
wire representation exists only to distinguish removed nullable fields.
Preserve presence semantics for the top-level list and strict unknown-field
rejection.
- Rewrite `dnd_combat_turns_llm.v1.json` while retaining its private schema and
prompt identities, and reduce the DTO/mapping to actor, turn kind, and
model-facing ranges. Keep the semantic mapping policy identifier unchanged.
- Rewrite combat task/instruction assets around detecting ordered turns and
discrete interrupting events. Remove round, action, target, declaration,
resolution, outcome, and summary duties. Remove the shared immediate-
resolution message from the combat manifest.
- Once both spell and combat manifests no longer use it, delete
`common-dnd-immediate-resolution.md` and its shared and module asset
registrations. Retain all other shared evidence, identity, reference, NPC,
and transcript assets in their cache-friendly order.
### 3.2 Simplify combat normalization and validation
- Keep combat normalization at `dnd.combat_turns.normalize.v1`. Continue to
display-normalize and registry-canonicalize actors, canonicalize evidence,
order records chronologically, and collapse exact duplicates by actor, turn
kind, and complete valid evidence. Delete action/target/prose normalization
and warning codes.
- Keep the invariant policy identifier unchanged. It checks canonical actor
display, canonical and chronological evidence, stable event ordering, and
absence of duplicate event identities; it performs no nested-action checks.
- Keep the shape policy identifier unchanged and validate only list presence,
nonblank actor, allowed turn kind, and nonempty source references.
- Keep the source-relatedness policy identifier unchanged and compare only the
actor against cited transcript material. Remove declaration-token heuristics
and their now-unused helpers. Keep source-reference validation unchanged if
its semantics did not change.
- Simplify merge/clone behavior to copy only retained values and source refs.
Preserve stable ordering and defensive ownership.
### 3.3 Update owners and tests
- Update codec, private schema, extractor, normalizer, validators, merge,
registration, pipeline integration, CLI fixtures, and examples to the minimal
shape. Rewrite or delete rich-schema fixtures rather than retaining them as
compatibility cases.
- Delete tests whose sole policy was round/action/summary handling. Add or
rewrite focused tests for strict schemas, enum validation, mapping and
source-ID assignment, actor canonicalization through the redesigned NPC
registry, chronology, exact duplicate collapse, and invariant validation.
- Update `docs/integrations/dnd-combat-turn-artifacts.md` as the canonical
redesigned v1 contract and update current internal module/LLM documentation
for the implemented behavior.
### Stage 3 completion criteria
- No production combat type, prompt, schema, policy, normalizer, fixture, or
current documentation refers to rounds, actions, summaries, declarations,
targets, resolutions, or action categories.
- The immediate-resolution shared asset has no remaining registration or file.
- Focused combat and pipeline tests pass, and `go test ./...` passes.
## Stage 4: Complete The Repository-Wide Cutover
### 4.1 Audit contract identity and stale surface area
- Search code, embedded assets, tests, examples, and current documentation for
all removed field names and unintended v2 schema/prompt names. Removed fields
may remain only in historical ADR or roadmap context; active schema, prompt,
fixture, and current-behavior surfaces must describe the minimal v1 contract.
- Verify the three artifact registrations still bind their original kinds and
exact redesigned Go types through extract, merge, normalize, codec, and
validators.
- Verify all prompt manifests still point at their v1 private schemas, stable
reference material precedes module-variable material, and transcript content
remains last. Do not add a change-detector test for prompt message count,
prose, shared-prefix content, or prefix length.
- Verify pre-redesign development checkpoints naturally miss through changed
prompt/schema content and generated-dependency fingerprints. Do not add
migration or compatibility handling for disposable pre-release state.
### 4.2 Exercise representative assembled behavior
- Keep unit case matrices at their owning schema, validator, normalizer, and
registry boundaries. At the assembled-pipeline boundary, retain only
representative tests proving each redesigned lane is registered, strict
JSON/schema failures are attributed to the correct validator, semantic
failures are attributed to the correct domain validator, and generated NPC
output is accepted by later spell/combat stages.
- Cover an ordered multi-step run in which NPC extraction produces the minimal
artifact and spell/combat consume its name projection. Assert that downstream
event source references point only to the current transcript and not to NPC
registry evidence.
- Verify CLI logical output and manifests retain the v1 schema/prompt identities,
full NPC registry provenance, and the relevant component fingerprints without
exposing prompt bodies or reference payloads.
### 4.3 Finish documentation and lifecycle state
- Reconcile the three integration documents, `docs/internal/modules.md`, and
`docs/internal/llm.md` with the final code. Correct any stale validator-order
descriptions while doing so; current docs must describe the registered order,
not preserve an older generic ordering example.
- Mark ADR-0009 `Accepted` and the feature roadmap `Implemented` when the code,
tests, and current-behavior documentation all land. Remove the completed item
from `docs/roadmap/future.md`; retain the feature roadmap and ADR as design
rationale unless the repository's normal roadmap-retirement practice calls
for moving the completed roadmap later.
- Record any human-reviewed rich/minimal model evaluation separately from CI
results. Evaluation may motivate later prompt tuning but does not reopen the
approved durable minimal field set within this implementation.
### 4.4 Final verification
Run, in order:
```sh
gofmt -w <changed Go files>
go test ./... go test ./...
go vet ./... go vet ./...
go build ./cmd/notarius go build ./cmd/notarius
go test -race ./internal/modules/dnd/... ./internal/framework/pipeline ./internal/cli ./internal/modules/integration go test -race ./internal/modules/dnd/...
git diff --check
``` ```
Review current-behavior documentation for stale statements that private spell, If the repository-wide race command exposes an unrelated, pre-existing failure,
NPC, or combat schemas own semantic validation. Confirm the scene schema and document it with the narrower affected package result; do not weaken or skip
prompt remain unchanged: their enumerations are explicitly communicated and ordinary tests for the changed D&D packages.
scene-plan construction has a distinct structural mapping boundary.
### Stage 4 completion criteria
- All three lanes use only their minimal v1 contracts from model response
through durable output.
- No compatibility shim, removed-field policy, unintended v2 asset, or stale
current-behavior documentation remains.
- Generated NPC references are name-only for LLM input and remain full-fidelity
for durable provenance.
- Repository tests, vet, build, race checks for the changed domain, and diff
hygiene checks pass.
## Open Questions ## Open Questions
None. The stages above define the validation ownership, default ordering, None. The feature roadmap, ADR, and fixed decisions above define the cutover,
compatibility policy, diagnostic behavior, and test boundaries required for pre-release schema, evidence, projection, validation, testing, and documentation
implementation. policies needed to implement each stage without further product decisions.

View File

@@ -0,0 +1,220 @@
# Minimal D&D Extraction Contracts
**Status:** Implemented
## Intent
Redesign the D&D spell, NPC, and combat-turn artifacts around the principle in
[ADR-0009](../adr/0009-minimal-evidence-grounded-extraction-artifacts.md):
each extractor should answer one narrow question with the smallest useful set
of source-grounded fields.
The redesign favors extraction precision, evidence quality, valid-output rate,
smaller-model reliability, and lower prompt and response cost over descriptive
richness. It removes synthesis responsibilities rather than preserving obsolete
fields as optional, nullable, empty, or application-generated placeholders.
## Goals
- Make every model-produced field necessary to the artifact's core question.
- Require direct transcript evidence for every extracted record.
- Remove overlapping prose, inferred enrichment, and nested structures without
a demonstrated consumer.
- Keep catalog and identity references as disambiguation aids rather than
evidence.
- Preserve deterministic canonicalization, evidence ordering, exact
deduplication, identity assignment, and bounded domain diagnostics where
those responsibilities still apply.
- Reduce downstream prompt material to the fields a consumer actually needs.
- Keep the unpublished v1 identities while replacing their pre-release shapes
in place.
## Non-Goals
- Generating session narrative, rules analysis, biographies, relationship
graphs, encounter summaries, or prose descriptions.
- Preserving removed fields for source compatibility through empty strings,
nullable values, or synthetic defaults.
- Adding fuzzy entity resolution, LLM-assisted enrichment, or a general schema
migration framework.
- Treating campaign references, catalogs, or earlier artifacts as evidence that
an event occurred in the current transcript.
- Combining the three D&D artifact families into one model call.
## Shared Contract Policy
All three artifacts remain ordered lists. Each record contains at least one
source reference, and the complete reference collection supports every
model-produced field in that record. Source IDs continue to be assigned by the
application from the current input; the model returns only source-unit ranges.
Private LLM schemas remain strict about their transport envelope: required
fields, JSON types, object and array shape, nullability where applicable, and
unknown-field rejection. Deterministic validators continue to own semantic
rules such as nonblank identities, catalog membership, enum membership,
positive and resolvable source units, and canonical normalized invariants.
Prompts retain the shared D&D evidence, identity, reference, NPC-grounding, and
transcript assets that remain relevant. Module-specific task and instruction
assets must delete duties associated with removed fields. The existing
cache-friendly ordering keeps stable shared and reference material before the
chunk-variable transcript.
## Spell Cast
The spell extractor answers:
> Which spell was cast, by which in-world caster, and where is that event
> established in the source?
The artifact kind remains `dnd/spell-list`. Its durable schema remains v1.
Each spell-cast record contains exactly:
- `caster`: required nonblank in-world display identity;
- `spell`: required nonblank canonical or catalog-resolvable spell name; and
- `source_refs`: one or more current-source evidence ranges.
The model-facing response contains the same fields except for application-owned
`source_id` values within references. The current `effect` and
`narrative_description` fields are removed from the private response, public Go
type, durable schema, codec, validators, normalizer, fixtures, and integration
contract.
The inclusion boundary remains an actual casting event or an unambiguously
declared casting attempt, not a spell mention, hypothetical plan, rules
discussion, or catalog match. The spell catalog helps recognize and canonicalize
the name but never establishes that a cast occurred.
Normalization continues to canonicalize spell names, canonicalize evidence,
and collapse exact duplicate events using caster, canonical spell name, and
complete valid evidence. It performs no prose selection or merging.
## NPC Registry
The NPC extractor answers:
> Which individually identifiable non-player characters are established in the
> source, and where is each identity established?
The artifact kind remains `dnd/npc-list`. Its durable schema remains v1.
Each durable NPC record contains exactly:
- `id`: deterministic application-assigned identity derived under the NPC
identity policy;
- `name`: required nonblank source-supported display identity; and
- `source_refs`: one or more evidence ranges supporting that identity.
The private model response omits `id` and reference `source_id` values. A
`name` may be a proper name or a stable, individually distinguishing title or
alias supported by the transcript. The extractor does not invent descriptive
labels for anonymous creatures, crowds, or generic roles.
The current `aliases`, `description`, and `relationships` fields are removed
from the private response, public Go type, durable schema, codec, validators,
normalizer, registry, fixtures, and integration contract. Normalization
consolidates only identities supported by the retained name policy and unions
exact evidence; it does not infer alias equivalence or relationships.
Spell and combat consumers receive a prompt projection containing only the
canonical NPC names needed for identity grounding. Application-owned NPC IDs
remain available to deterministic registry and normalization code but are not
sent to a model that cannot return or otherwise consume them. NPC source
references remain provenance in the durable registry and are not included as
current-transcript evidence or copied into downstream event artifacts.
Encounter context is deliberately not a scalar NPC registry field. Dialogue,
combat alignment, presence, or third-party mention can vary across occurrences.
If a demonstrated consumer needs that information, add a separate ordered
NPC-occurrence artifact whose records contain `name`, a small mutually
exclusive context enum, and `source_refs`. A candidate starting vocabulary is
`dialogue`, `combat_ally`, `combat_opponent`, `noncombat_presence`, `mentioned`,
and `other`; its exact semantics require a separate feature decision.
## Combat Event
The combat extractor answers:
> Which in-world participant took a turn or discrete interrupting combat
> event, what kind of event was it, and where is it established in the source?
The existing `dnd/combat-turn-list` artifact kind and v1 durable-schema identity
remain. Each record contains exactly:
- `actor`: required nonblank in-world display identity;
- `turn_kind`: one of `turn`, `reaction`, `legendary_action`, `lair_action`, or
`other`; and
- `source_refs`: one or more current-source evidence ranges.
The current `round`, `actions`, and `summary` fields, including nested action
categories, declarations, targets, and resolutions, are removed from the
private response, public Go types, durable schema, codec, validators,
normalizer, fixtures, and integration contract.
Normalization continues to display-normalize and registry-canonicalize actors,
canonicalize evidence, order events by source position, and collapse exact
duplicates using actor, turn kind, and complete valid evidence. It no longer
normalizes targets, declarations, summaries, or resolutions.
If action-level facts later have a demonstrated consumer, they belong in a
separate combat-action artifact rather than restoring a nested synthesis
contract to combat-turn detection. Spell casts and future item events remain
owned by their dedicated artifact lanes.
## Pre-Release Schema Policy
Notarius and these contracts are pre-release. The existing v1 artifacts and
private model-response schemas have not been published as compatibility
contracts, so their shapes change in place. The implementation does not retain
the rich pre-redesign shape, add v2 assets, migrate old output, support multiple
versions, or preserve old fixtures solely for compatibility testing.
Artifact kinds, schema keys and IDs, schema names and versions, prompt IDs and
versions, module keys, capabilities, and media types all remain unchanged.
Changing prompt and schema content invalidates the relevant content-addressed
development state; any remaining pre-redesign local output or cache is
disposable and may be regenerated.
| Lane | Durable v1 schema | Private model-response v1 schema | Prompt ID |
| --- | --- | --- | --- |
| Spell cast | ID `notarius.dnd.spells`, name `notarius_dnd_spells_v1` | key `dnd_spells`, ID `notarius.dnd.spells`, name `notarius_dnd_spells_v1` | `dnd.spells` |
| NPC registry | ID `notarius.dnd.npcs`, name `notarius_dnd_npcs_v1` | key `dnd_npcs_llm`, ID `notarius.dnd.npcs.llm`, name `notarius_dnd_npcs_llm_v1` | `dnd.npcs` |
| Combat event | ID `notarius.dnd.combat_turns`, name `notarius_dnd_combat_turns_v1` | key `dnd_combat_turns_llm`, ID `notarius.dnd.combat_turns.llm`, name `notarius_dnd_combat_turns_llm_v1` | `dnd.combat_turns` |
## Quality And Evaluation
The implemented contract should protect the remaining meaningful risks:
- private schemas accept only the new structural envelopes;
- durable codecs strictly round-trip the redesigned contracts;
- domain validators own blank identities, enum and catalog membership, and
invalid evidence;
- normalizers preserve source-grounded values while applying only their stated
deterministic transformations;
- generated NPC references expose the minimal identity projection and never
become event evidence; and
- representative assembled pipelines attribute retries and rejections to the
owning domain boundary.
Post-cutover model evaluation should compare the rich-schema baseline and the
minimal-schema result on a small human-reviewed transcript set using:
- event/entity precision and recall;
- caster, NPC, and combat-actor attribution accuracy;
- source-range validity and evidence sufficiency;
- catalog and enum accuracy;
- structurally valid completion rate and exhausted-retry rate;
- unsupported-claim rate; and
- input/output tokens, latency, and model cost.
The evaluation exists to identify prompt or model-quality follow-up work, not
to gate the approved minimal contract on live-provider behavior. Human review
and live model calls are evaluation aids rather than deterministic CI gates.
## Documentation Ownership
The spell, NPC, and combat integration documents are the canonical owners of
their redesigned durable schemas. Internal LLM and module documents own the
corresponding current prompt, DTO, validator, normalizer, and NPC
prompt-projection behavior. This roadmap records the implemented design and
policy; [the implementation plan](implementation.md) records the completed
sequencing and completion criteria.

View File

@@ -57,8 +57,8 @@ func TestAssembledSpellPipelineNormalizesMergedCasts(t *testing.T) {
t.Fatalf("normalized casts = %#v, want collapsed duplicate plus distinct evidence", normalized.SpellCasts) t.Fatalf("normalized casts = %#v, want collapsed duplicate plus distinct evidence", normalized.SpellCasts)
} }
first, distinct := normalized.SpellCasts[0], normalized.SpellCasts[1] first, distinct := normalized.SpellCasts[0], normalized.SpellCasts[1]
if first.Spell != "Cure Wounds" || first.Caster != " Aria \t" || first.Effect != "first occurrence" || first.NarrativeDescription != "first narrative" { if first.Spell != "Cure Wounds" || first.Caster != " Aria \t" {
t.Fatalf("retained cast = %#v, want canonical spell with first occurrence fields", first) t.Fatalf("retained cast = %#v, want canonical spell with first occurrence caster", first)
} }
if !reflect.DeepEqual(first.SourceRefs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) { if !reflect.DeepEqual(first.SourceRefs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) {
t.Fatalf("retained refs = %#v, want sorted complete evidence", first.SourceRefs) t.Fatalf("retained refs = %#v, want sorted complete evidence", first.SourceRefs)
@@ -271,7 +271,7 @@ func (e *assembledSpellExtractor) Extract(ctx context.Context, req contracts.Typ
if e.unknownSpell { if e.unknownSpell {
if req.Chunk.Index == 0 { if req.Chunk.Index == 0 {
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{ return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Caster: "Aria", Spell: "Mysterious Burst", Effect: "an unknown magical effect", NarrativeDescription: "Aria produces a mysterious burst.", SourceRefs: []source.SourceRef{refOne}, Caster: "Aria", Spell: "Mysterious Burst", SourceRefs: []source.SourceRef{refOne},
}}}}, nil }}}}, nil
} }
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}}, nil return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}}, nil
@@ -279,12 +279,12 @@ func (e *assembledSpellExtractor) Extract(ctx context.Context, req contracts.Typ
switch req.Chunk.Index { switch req.Chunk.Index {
case 0: case 0:
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{ return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Caster: " Aria \t", Spell: " cure wounds ", Effect: "first occurrence", NarrativeDescription: "first narrative", SourceRefs: []source.SourceRef{refTwo, refOne}, Caster: " Aria \t", Spell: " cure wounds ", SourceRefs: []source.SourceRef{refTwo, refOne},
}}}}, nil }}}}, nil
case 1: case 1:
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{ return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{
{Caster: "aria", Spell: "Cure Wounds", Effect: "removed occurrence", NarrativeDescription: "removed narrative", SourceRefs: []source.SourceRef{refOne, refTwo}}, {Caster: "aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{refOne, refTwo}},
{Caster: "aria", Spell: "Cure Wounds", Effect: "different evidence", NarrativeDescription: "different narrative", SourceRefs: []source.SourceRef{refTwo}}, {Caster: "aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{refTwo}},
}}}, nil }}}, nil
default: default:
return contracts.TypedExtractionResult[dnd.SpellList]{}, fmt.Errorf("unexpected assembled chunk index %d", req.Chunk.Index) return contracts.TypedExtractionResult[dnd.SpellList]{}, fmt.Errorf("unexpected assembled chunk index %d", req.Chunk.Index)

View File

@@ -55,17 +55,17 @@ func TestProductionCombatConfigurationResolvesTypedLane(t *testing.T) {
wantExtractChain := []pipeline.ModuleBinding{ wantExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/shape"), pipeline.Binding("extract/dnd/combat-turns/shape"),
pipeline.Binding("extract/dnd/combat-turns/source_refs"), pipeline.Binding("extract/dnd/combat-turns/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"), pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
} }
wantNormalizeChain := []pipeline.ModuleBinding{ wantNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/shape"), pipeline.Binding("extract/dnd/combat-turns/shape"),
pipeline.Binding("normalize/dnd/combat-turns/invariants"), pipeline.Binding("normalize/dnd/combat-turns/invariants"),
pipeline.Binding("extract/dnd/combat-turns/source_refs"), pipeline.Binding("extract/dnd/combat-turns/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"), pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
} }
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, wantExtractChain) { if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, wantExtractChain) {

View File

@@ -8,6 +8,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/config" "gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
) )
@@ -47,20 +48,24 @@ func TestProductionNPCConfigurationResolvesTypedLane(t *testing.T) {
if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) { if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) {
t.Fatalf("NPC normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec) t.Fatalf("NPC normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec)
} }
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCListKind)
if !ok || codecSpec.Kind != dnd.NPCListKind || codecSpec.Schema.ID != npccodec.SchemaID || codecSpec.Schema.Version != npccodec.SchemaVersion {
t.Fatalf("NPC codec spec = %#v, want typed v1 durable schema", codecSpec)
}
wantExtractChain := []pipeline.ModuleBinding{ wantExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/npcs/shape"), pipeline.Binding("extract/dnd/npcs/shape"),
pipeline.Binding("extract/dnd/npcs/source_refs"), pipeline.Binding("extract/dnd/npcs/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/npcs/source_relatedness"), pipeline.Binding("extract/dnd/npcs/source_relatedness"),
} }
wantNormalizeChain := []pipeline.ModuleBinding{ wantNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/npcs/shape"), pipeline.Binding("extract/dnd/npcs/shape"),
pipeline.Binding("normalize/dnd/npcs/identity"), pipeline.Binding("normalize/dnd/npcs/identity"),
pipeline.Binding("extract/dnd/npcs/source_refs"), pipeline.Binding("extract/dnd/npcs/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/npcs/source_relatedness"), pipeline.Binding("extract/dnd/npcs/source_relatedness"),
} }
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, npcextract.Key); !reflect.DeepEqual(got, wantExtractChain) { if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, npcextract.Key); !reflect.DeepEqual(got, wantExtractChain) {

View File

@@ -63,10 +63,10 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
wantChain := []pipeline.ModuleBinding{ wantChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/spells/shape"), pipeline.Binding("extract/dnd/spells/shape"),
pipeline.Binding("extract/dnd/spells/catalog"), pipeline.Binding("extract/dnd/spells/catalog"),
pipeline.Binding("extract/dnd/spells/source_refs"), pipeline.Binding("extract/dnd/spells/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/spells/source_relatedness"), pipeline.Binding("extract/dnd/spells/source_relatedness"),
} }
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) { if got := registries.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
@@ -77,17 +77,17 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
} }
combatExtractChain := []pipeline.ModuleBinding{ combatExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/shape"), pipeline.Binding("extract/dnd/combat-turns/shape"),
pipeline.Binding("extract/dnd/combat-turns/source_refs"), pipeline.Binding("extract/dnd/combat-turns/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"), pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
} }
combatNormalizeChain := []pipeline.ModuleBinding{ combatNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/shape"), pipeline.Binding("extract/dnd/combat-turns/shape"),
pipeline.Binding("normalize/dnd/combat-turns/invariants"), pipeline.Binding("normalize/dnd/combat-turns/invariants"),
pipeline.Binding("extract/dnd/combat-turns/source_refs"), pipeline.Binding("extract/dnd/combat-turns/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"), pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
} }
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, combatExtractChain) { if got := registries.ValidatorChains.Validators(pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, combatExtractChain) {
@@ -438,7 +438,7 @@ func TestProductionConfigValidationCoversModuleAndVariantFailures(t *testing.T)
func TestProductionNormalizeValidatorOverrideRemainsAuthoritative(t *testing.T) { func TestProductionNormalizeValidatorOverrideRemainsAuthoritative(t *testing.T) {
base := string(readRepositoryFile(t, "examples", "dnd-spells.config.yml")) base := string(readRepositoryFile(t, "examples", "dnd-spells.config.yml"))
content := replaceRequiredOnce(t, base, " normalize: dnd/spells\n", " normalize:\n module: dnd/spells\n validators:\n - module: generic/always_accept\n") content := replaceRequiredOnce(t, base, " normalize: dnd/spells\n", " normalize:\n module: dnd/spells\n validators:\n - module: generic/always_accept\n - module: generic/valid_json\n")
path := writeProductionContractConfig(t, content) path := writeProductionContractConfig(t, content)
components := productionTestComponents(t) components := productionTestComponents(t)
effective, err := loadMaintainedExample(t, path).Resolve(resolveInputForMaintainedExample(components, "dnd-session")) effective, err := loadMaintainedExample(t, path).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
@@ -449,8 +449,8 @@ func TestProductionNormalizeValidatorOverrideRemainsAuthoritative(t *testing.T)
if chain.Stage != pipeline.StageNormalize || chain.ModuleKey != spellnormalize.Key { if chain.Stage != pipeline.StageNormalize || chain.ModuleKey != spellnormalize.Key {
continue continue
} }
if len(chain.Validators) != 1 || chain.Validators[0].Binding.Module != "generic/always_accept" { if len(chain.Validators) != 2 || chain.Validators[0].Binding.Module != "generic/always_accept" || chain.Validators[1].Binding.Module != "generic/valid_json" {
t.Fatalf("normalize validator chain = %#v, want explicit always-accept override", chain) t.Fatalf("normalize validator chain = %#v, want explicit validator order", chain)
} }
return return
} }
@@ -672,7 +672,7 @@ func (client *productionFakeLLMClient) CompleteStructured(ctx context.Context, r
if client.spellResponse != "" { if client.spellResponse != "" {
content = []byte(client.spellResponse) content = []byte(client.spellResponse)
} else { } else {
content = []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"Heals an injured ally.","narrative_description":"Aria restores the fighter after the fight.","source_refs":[{"source_id":"session-alpha","start_unit_id":1,"end_unit_id":1}]}]}`) content = []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)
} }
default: default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID) return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)

View File

@@ -154,9 +154,9 @@ func TestSemanticSpellCatalogFingerprintChangesCheckpointIdentityWithoutReferenc
fingerprints := prepared.CheckpointFingerprints() fingerprints := prepared.CheckpointFingerprints()
wantNames := map[string]struct{}{ wantNames := map[string]struct{}{
"extract:spells:" + spells.Key + ":effective_catalog": {}, "extract:spells:" + spells.Key + ":effective_catalog": {},
"extract:spells:" + spells.Key + ":validator:3:extract/dnd/spells/catalog:effective_catalog": {}, "extract:spells:" + spells.Key + ":validator:2:extract/dnd/spells/catalog:effective_catalog": {},
"normalize:spells:" + spellnormalize.Key + ":effective_catalog": {}, "normalize:spells:" + spellnormalize.Key + ":effective_catalog": {},
"normalize:spells:" + spellnormalize.Key + ":validator:3:extract/dnd/spells/catalog:effective_catalog": {}, "normalize:spells:" + spellnormalize.Key + ":validator:2:extract/dnd/spells/catalog:effective_catalog": {},
} }
seen := make(map[string]string, len(fingerprints)) seen := make(map[string]string, len(fingerprints))
for _, fingerprint := range fingerprints { for _, fingerprint := range fingerprints {

View File

@@ -150,8 +150,6 @@ func productionSpellResponse(name string) string {
content, err := json.Marshal(dnd.SpellList{SpellCasts: []dnd.SpellCast{{ content, err := json.Marshal(dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Caster: "Aria", Caster: "Aria",
Spell: name, Spell: name,
Effect: "The spell takes effect.",
NarrativeDescription: "Aria casts the spell.",
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}}, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}},
}}}) }}})
if err != nil { if err != nil {

View File

@@ -52,13 +52,13 @@ func (l *FilesystemLoader) Source(moduleKey string) (pipeline.SourceCheckpoint,
} }
doc := cloneSourceDocument(payload.Document) doc := cloneSourceDocument(payload.Document)
if err := source.ValidateDocument(&doc); err != nil { if err := source.ValidateDocument(&doc); err != nil {
return pipeline.SourceCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactPayloadInvalid, "source checkpoint document is invalid") return pipeline.SourceCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactPayloadInvalid)
} }
if strings.TrimSpace(manifest.SourceID) != "" && manifest.SourceID != doc.ID { if strings.TrimSpace(manifest.SourceID) != "" && manifest.SourceID != doc.ID {
return pipeline.SourceCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactPayloadInvalid, "source checkpoint identity does not match its payload") return pipeline.SourceCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactPayloadInvalid)
} }
if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.OutputDigests), digestFingerprints("source_document", doc.Digest)) { if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.OutputDigests), digestFingerprints("source_document", doc.Digest)) {
return pipeline.SourceCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactDigestMismatch, "source checkpoint output digest does not match its payload") return pipeline.SourceCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactDigestMismatch)
} }
return pipeline.SourceCheckpoint{Document: &doc}, reusedDecision() return pipeline.SourceCheckpoint{Document: &doc}, reusedDecision()
} }
@@ -84,7 +84,7 @@ func (l *FilesystemLoader) ExtractForStep(stepID, laneID, moduleKey string, depe
return pipeline.ExtractCheckpoint{}, artifactDecision(err, "extract checkpoint artifact is invalid") return pipeline.ExtractCheckpoint{}, artifactDecision(err, "extract checkpoint artifact is invalid")
} }
if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.OutputDigests), artifactOutputDigests(outputs)) { if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.OutputDigests), artifactOutputDigests(outputs)) {
return pipeline.ExtractCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactDigestMismatch, "extract checkpoint output digest does not match its payload") return pipeline.ExtractCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactDigestMismatch)
} }
return pipeline.ExtractCheckpoint{Outputs: outputs, Rejected: cloneRejectedOutputs(payload.Rejected), Warnings: cloneWarnings(payload.Warnings)}, reusedDecision() return pipeline.ExtractCheckpoint{Outputs: outputs, Rejected: cloneRejectedOutputs(payload.Rejected), Warnings: cloneWarnings(payload.Warnings)}, reusedDecision()
} }
@@ -110,7 +110,7 @@ func (l *FilesystemLoader) MergeForStep(stepID, laneID, moduleKey string, depend
return pipeline.MergeCheckpoint{}, artifactDecision(err, "merge checkpoint artifact is invalid") return pipeline.MergeCheckpoint{}, artifactDecision(err, "merge checkpoint artifact is invalid")
} }
if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.OutputDigests), artifactOutputDigests(values)) { if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.OutputDigests), artifactOutputDigests(values)) {
return pipeline.MergeCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactDigestMismatch, "merge checkpoint output digest does not match its payload") return pipeline.MergeCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactDigestMismatch)
} }
return pipeline.MergeCheckpoint{Output: values[0], Warnings: cloneWarnings(payload.Warnings)}, reusedDecision() return pipeline.MergeCheckpoint{Output: values[0], Warnings: cloneWarnings(payload.Warnings)}, reusedDecision()
} }
@@ -136,7 +136,7 @@ func (l *FilesystemLoader) NormalizeForStep(stepID, laneID, moduleKey string, de
return pipeline.NormalizeCheckpoint{}, artifactDecision(err, "normalize checkpoint artifact is invalid") return pipeline.NormalizeCheckpoint{}, artifactDecision(err, "normalize checkpoint artifact is invalid")
} }
if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.OutputDigests), artifactOutputDigests(values)) { if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.OutputDigests), artifactOutputDigests(values)) {
return pipeline.NormalizeCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactDigestMismatch, "normalize checkpoint output digest does not match its payload") return pipeline.NormalizeCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactDigestMismatch)
} }
return pipeline.NormalizeCheckpoint{Output: values[0], Warnings: cloneWarnings(payload.Warnings)}, reusedDecision() return pipeline.NormalizeCheckpoint{Output: values[0], Warnings: cloneWarnings(payload.Warnings)}, reusedDecision()
} }
@@ -158,36 +158,36 @@ func (l *FilesystemLoader) AcceptedNormalize(stepID, laneID, moduleKey string) (
return pipeline.NormalizeCheckpoint{}, artifactDecision(err, "accepted normalize checkpoint artifact is invalid") return pipeline.NormalizeCheckpoint{}, artifactDecision(err, "accepted normalize checkpoint artifact is invalid")
} }
if len(values) != 1 { if len(values) != 1 {
return pipeline.NormalizeCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactPayloadInvalid, "accepted normalize checkpoint payload is invalid") return pipeline.NormalizeCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactPayloadInvalid)
} }
if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.OutputDigests), artifactOutputDigests(values)) { if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.OutputDigests), artifactOutputDigests(values)) {
return pipeline.NormalizeCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactDigestMismatch, "accepted normalize checkpoint digest does not match its payload") return pipeline.NormalizeCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactDigestMismatch)
} }
return pipeline.NormalizeCheckpoint{Output: values[0], Warnings: cloneWarnings(payload.Warnings)}, decision(pipeline.CheckpointDecisionReused, pipeline.CheckpointReasonAcceptedArtifactReused, "accepted normalized artifact is reusable") return pipeline.NormalizeCheckpoint{Output: values[0], Warnings: cloneWarnings(payload.Warnings)}, decision(pipeline.CheckpointDecisionReused, pipeline.CheckpointReasonAcceptedArtifactReused)
} }
func (l *FilesystemLoader) validateAcceptedNormalizeManifest(manifest StageManifest, stepID, laneID, moduleKey string) pipeline.CheckpointDecision { func (l *FilesystemLoader) validateAcceptedNormalizeManifest(manifest StageManifest, stepID, laneID, moduleKey string) pipeline.CheckpointDecision {
if manifest.WorkspaceSchemaVersion != WorkspaceSchemaVersion { if manifest.WorkspaceSchemaVersion != WorkspaceSchemaVersion {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonWorkspaceSchemaIncompatible, "checkpoint workspace schema is incompatible") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonWorkspaceSchemaIncompatible)
} }
identity := strings.TrimSpace(l.identityDigest) identity := strings.TrimSpace(l.identityDigest)
if identity == "" || strings.TrimSpace(manifest.Metadata["checkpoint_identity_digest"]) == "" || manifest.Metadata["checkpoint_identity_digest"] != identity { if identity == "" || strings.TrimSpace(manifest.Metadata["checkpoint_identity_digest"]) == "" || manifest.Metadata["checkpoint_identity_digest"] != identity {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonIdentityMismatch, "checkpoint identity is unavailable or does not match the current invocation") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonIdentityMismatch)
} }
if manifest.Stage != StageNormalize { if manifest.Stage != StageNormalize {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonStageMismatch, "checkpoint stage does not match normalize") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonStageMismatch)
} }
if strings.TrimSpace(stepID) == "" || manifest.StepID != stepID { if strings.TrimSpace(stepID) == "" || manifest.StepID != stepID {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonStepMismatch, "checkpoint step does not match the requested step") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonStepMismatch)
} }
if strings.TrimSpace(laneID) == "" || manifest.LaneID != laneID { if strings.TrimSpace(laneID) == "" || manifest.LaneID != laneID {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonLaneMismatch, "checkpoint lane does not match the requested lane") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonLaneMismatch)
} }
if strings.TrimSpace(moduleKey) == "" || manifest.ModuleKey != moduleKey { if strings.TrimSpace(moduleKey) == "" || manifest.ModuleKey != moduleKey {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonModuleMismatch, "checkpoint module does not match the requested normalizer") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonModuleMismatch)
} }
if manifest.Status != StatusSucceeded { if manifest.Status != StatusSucceeded {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonStatusNotReusable, "checkpoint status cannot provide an accepted normalized artifact") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonStatusNotReusable)
} }
return reusedDecision() return reusedDecision()
} }
@@ -212,21 +212,21 @@ func artifactCheckpointOutputs(values []artifactCheckpointEnvelope) ([]pipeline.
func (l *FilesystemLoader) readJSON(name string, out any) pipeline.CheckpointDecision { func (l *FilesystemLoader) readJSON(name string, out any) pipeline.CheckpointDecision {
if !l.Enabled() { if !l.Enabled() {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonLoadingDisabled, "checkpoint loading disabled") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonLoadingDisabled)
} }
target, err := fileio.SafePath(l.root, name) target, err := fileio.SafePath(l.root, name)
if err != nil { if err != nil {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonPathInvalid, "checkpoint path is invalid") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonPathInvalid)
} }
data, err := os.ReadFile(target) data, err := os.ReadFile(target)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonMissing, "checkpoint artifact is missing") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonMissing)
} }
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonReadFailed, "checkpoint artifact could not be read") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonReadFailed)
} }
if err := json.Unmarshal(data, out); err != nil { if err := json.Unmarshal(data, out); err != nil {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonDecodeFailed, "checkpoint artifact could not be decoded") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonDecodeFailed)
} }
return reusedDecision() return reusedDecision()
} }
@@ -237,28 +237,28 @@ func (l *FilesystemLoader) validateManifest(manifest StageManifest, stage StageN
func (l *FilesystemLoader) validateLaneManifest(manifest StageManifest, stage StageName, stepID string, laneID string, moduleKey string, dependencies []pipeline.CheckpointFingerprint, statuses ...StageStatus) pipeline.CheckpointDecision { func (l *FilesystemLoader) validateLaneManifest(manifest StageManifest, stage StageName, stepID string, laneID string, moduleKey string, dependencies []pipeline.CheckpointFingerprint, statuses ...StageStatus) pipeline.CheckpointDecision {
if manifest.WorkspaceSchemaVersion == WorkspaceSchemaVersionV1 { if manifest.WorkspaceSchemaVersion == WorkspaceSchemaVersionV1 {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonWorkspaceSchemaIncompatible, "checkpoint workspace schema is incompatible") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonWorkspaceSchemaIncompatible)
} }
if manifest.WorkspaceSchemaVersion == WorkspaceSchemaVersionV2 { if manifest.WorkspaceSchemaVersion == WorkspaceSchemaVersionV2 {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonWorkspaceSchemaIncompatible, "checkpoint workspace schema is incompatible") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonWorkspaceSchemaIncompatible)
} }
if manifest.WorkspaceSchemaVersion != WorkspaceSchemaVersion { if manifest.WorkspaceSchemaVersion != WorkspaceSchemaVersion {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonWorkspaceSchemaIncompatible, "checkpoint workspace schema is incompatible") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonWorkspaceSchemaIncompatible)
} }
if strings.TrimSpace(l.identityDigest) != "" && manifest.Metadata["checkpoint_identity_digest"] != l.identityDigest { if strings.TrimSpace(l.identityDigest) != "" && manifest.Metadata["checkpoint_identity_digest"] != l.identityDigest {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonIdentityMismatch, "checkpoint identity does not match the current invocation") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonIdentityMismatch)
} }
if manifest.Stage != stage { if manifest.Stage != stage {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonStageMismatch, "checkpoint stage does not match the requested stage") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonStageMismatch)
} }
if strings.TrimSpace(stepID) != "" && manifest.StepID != stepID { if strings.TrimSpace(stepID) != "" && manifest.StepID != stepID {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonStepMismatch, "checkpoint step does not match the requested step") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonStepMismatch)
} }
if strings.TrimSpace(laneID) != "" && manifest.LaneID != laneID { if strings.TrimSpace(laneID) != "" && manifest.LaneID != laneID {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonLaneMismatch, "checkpoint lane does not match the requested lane") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonLaneMismatch)
} }
if strings.TrimSpace(moduleKey) != "" && manifest.ModuleKey != moduleKey { if strings.TrimSpace(moduleKey) != "" && manifest.ModuleKey != moduleKey {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonModuleMismatch, "checkpoint module does not match the requested module") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonModuleMismatch)
} }
statusOK := false statusOK := false
for _, status := range statuses { for _, status := range statuses {
@@ -268,10 +268,10 @@ func (l *FilesystemLoader) validateLaneManifest(manifest StageManifest, stage St
} }
} }
if !statusOK { if !statusOK {
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonStatusNotReusable, "checkpoint status cannot be reused") return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonStatusNotReusable)
} }
if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.DependencyFingerprints), dependencies) { if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.DependencyFingerprints), dependencies) {
return decision(pipeline.CheckpointDecisionDependencyInvalidated, pipeline.CheckpointReasonDependencyMismatch, "checkpoint dependencies do not match") return decision(pipeline.CheckpointDecisionDependencyInvalidated, pipeline.CheckpointReasonDependencyMismatch)
} }
return reusedDecision() return reusedDecision()
} }
@@ -313,11 +313,11 @@ func fingerprintsEqual(a []pipeline.CheckpointFingerprint, b []pipeline.Checkpoi
} }
func reusedDecision() pipeline.CheckpointDecision { func reusedDecision() pipeline.CheckpointDecision {
return decision(pipeline.CheckpointDecisionReused, pipeline.CheckpointReasonReused, "checkpoint is reusable") return decision(pipeline.CheckpointDecisionReused, pipeline.CheckpointReasonReused)
} }
func decision(category pipeline.CheckpointDecisionCategory, code pipeline.CheckpointReasonCode, detail string) pipeline.CheckpointDecision { func decision(category pipeline.CheckpointDecisionCategory, code pipeline.CheckpointReasonCode) pipeline.CheckpointDecision {
return pipeline.NewCheckpointDecision(category, code, detail) return pipeline.NewCheckpointDecision(category, code)
} }
type artifactPayloadError struct { type artifactPayloadError struct {
@@ -332,5 +332,5 @@ func artifactDecision(err error, detail string) pipeline.CheckpointDecision {
if payloadErr, ok := err.(*artifactPayloadError); ok { if payloadErr, ok := err.(*artifactPayloadError); ok {
code = payloadErr.code code = payloadErr.code
} }
return decision(pipeline.CheckpointDecisionExecuted, code, detail) return decision(pipeline.CheckpointDecisionExecuted, code)
} }

View File

@@ -97,18 +97,18 @@ type CheckpointDecision struct {
ReasonCode CheckpointReasonCode `json:"reason_code,omitempty"` ReasonCode CheckpointReasonCode `json:"reason_code,omitempty"`
Detail string `json:"detail,omitempty"` Detail string `json:"detail,omitempty"`
// Reason is retained as a compatibility/debug field for existing callers. // Reason is retained as a compatibility/debug field for existing callers.
// New checkpoint stores should put bounded, non-sensitive text in Detail. // Detail and Reason are derived from the stable reason code.
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
} }
const checkpointDecisionDetailLimit = 512 const checkpointDecisionDetailLimit = 512
func NewCheckpointDecision(category CheckpointDecisionCategory, reasonCode CheckpointReasonCode, detail string) CheckpointDecision { func NewCheckpointDecision(category CheckpointDecisionCategory, reasonCode CheckpointReasonCode) CheckpointDecision {
return checkpointDecision(category, reasonCode, detail) return checkpointDecision(category, reasonCode)
} }
func checkpointDecision(category CheckpointDecisionCategory, reasonCode CheckpointReasonCode, detail string) CheckpointDecision { func checkpointDecision(category CheckpointDecisionCategory, reasonCode CheckpointReasonCode) CheckpointDecision {
detail = sanitizeCheckpointDecisionDetail(detail) detail := normalizeCheckpointDecisionDetail(checkpointDecisionDetail(reasonCode))
return CheckpointDecision{ return CheckpointDecision{
Reused: category == CheckpointDecisionReused, Reused: category == CheckpointDecisionReused,
Category: category, Category: category,
@@ -118,12 +118,55 @@ func checkpointDecision(category CheckpointDecisionCategory, reasonCode Checkpoi
} }
} }
func sanitizeCheckpointDecisionDetail(detail string) string { func checkpointDecisionDetail(reasonCode CheckpointReasonCode) string {
detail = strings.TrimSpace(strings.ToValidUTF8(detail, "?")) switch reasonCode {
lower := strings.ToLower(detail) case CheckpointReasonLoadingDisabled:
if strings.ContainsAny(detail, `/\\`) || strings.Contains(lower, "secret") || strings.Contains(lower, "token") || strings.Contains(lower, "password") || strings.Contains(lower, "credential") || strings.Contains(lower, "environment") { return "checkpoint loading is disabled"
return "checkpoint decision detail redacted" case CheckpointReasonMissing:
return "checkpoint artifact is missing"
case CheckpointReasonPathInvalid:
return "checkpoint location is invalid"
case CheckpointReasonReadFailed:
return "checkpoint artifact could not be read"
case CheckpointReasonDecodeFailed:
return "checkpoint artifact could not be decoded"
case CheckpointReasonWorkspaceSchemaIncompatible:
return "checkpoint workspace schema is incompatible"
case CheckpointReasonIdentityMismatch:
return "checkpoint identity does not match the current invocation"
case CheckpointReasonStageMismatch:
return "checkpoint stage does not match"
case CheckpointReasonStepMismatch:
return "checkpoint step does not match"
case CheckpointReasonLaneMismatch:
return "checkpoint lane does not match"
case CheckpointReasonModuleMismatch:
return "checkpoint module does not match"
case CheckpointReasonStatusNotReusable:
return "checkpoint status is not reusable"
case CheckpointReasonDependencyMismatch:
return "checkpoint dependencies do not match"
case CheckpointReasonArtifactPayloadInvalid:
return "checkpoint artifact payload is invalid"
case CheckpointReasonArtifactDigestMismatch:
return "checkpoint artifact digest does not match"
case CheckpointReasonArtifactCodecIncompatible:
return "checkpoint artifact is incompatible with the registered codec"
case CheckpointReasonArtifactNotCanonical:
return "checkpoint artifact is not canonical"
case CheckpointReasonReused:
return "checkpoint is reusable"
case CheckpointReasonAcceptedArtifactReused:
return "accepted normalized artifact is reusable"
case CheckpointReasonRecomputeStep:
return "selected step requires execution"
default:
return "checkpoint decision"
} }
}
func normalizeCheckpointDecisionDetail(detail string) string {
detail = strings.TrimSpace(strings.ToValidUTF8(detail, "?"))
var b strings.Builder var b strings.Builder
for _, r := range detail { for _, r := range detail {
if r < 0x20 || r == 0x7f { if r < 0x20 || r == 0x7f {
@@ -170,7 +213,7 @@ func (policy CheckpointExecutionPolicy) requiresReusable(stepID, laneID string)
func forceCheckpointDecision(policy CheckpointExecutionPolicy, stepID, laneID string, decision CheckpointDecision) CheckpointDecision { func forceCheckpointDecision(policy CheckpointExecutionPolicy, stepID, laneID string, decision CheckpointDecision) CheckpointDecision {
if policy.forced(stepID, laneID) { if policy.forced(stepID, laneID) {
return checkpointDecision(CheckpointDecisionForcedRecompute, CheckpointReasonRecomputeStep, "selected step requires execution") return checkpointDecision(CheckpointDecisionForcedRecompute, CheckpointReasonRecomputeStep)
} }
return decision return decision
} }
@@ -184,23 +227,38 @@ func requireReusableCheckpoint(policy CheckpointExecutionPolicy, stepID, laneID
// resolveCheckpointDecision applies runner policy and canonical payload // resolveCheckpointDecision applies runner policy and canonical payload
// validation at the single point where a stage's observable decision is made. // validation at the single point where a stage's observable decision is made.
func resolveCheckpointDecision(output *RunOutput, loader CheckpointLoader, policy CheckpointExecutionPolicy, stage ModuleStage, stepID, laneID, moduleKey string, decision CheckpointDecision, codec artifactCodecEntry, artifacts []CheckpointArtifact) (CheckpointDecision, error) { type checkpointResolution struct {
decision CheckpointDecision
values []any
artifacts []CheckpointArtifact
}
func resolveCheckpointDecision(output *RunOutput, loader CheckpointLoader, policy CheckpointExecutionPolicy, stage ModuleStage, stepID, laneID, moduleKey string, decision CheckpointDecision, codec artifactCodecEntry, artifacts []CheckpointArtifact) (checkpointResolution, error) {
decision = forceCheckpointDecision(policy, stepID, laneID, decision) decision = forceCheckpointDecision(policy, stepID, laneID, decision)
resolution := checkpointResolution{decision: decision}
if decision.Reused { if decision.Reused {
resolution.values = make([]any, 0, len(artifacts))
resolution.artifacts = make([]CheckpointArtifact, 0, len(artifacts))
for _, artifact := range artifacts { for _, artifact := range artifacts {
if _, _, err := decodeCanonicalCheckpointArtifact(codec, artifact); err != nil { value, hydrated, err := decodeCanonicalCheckpointArtifact(codec, artifact)
decision = checkpointDecision(CheckpointDecisionExecuted, checkpointArtifactReasonCode(err), "stored "+string(stage)+" artifact failed canonical codec validation") if err != nil {
decision = checkpointDecision(CheckpointDecisionExecuted, checkpointArtifactReasonCode(err))
resolution.values = nil
resolution.artifacts = nil
break break
} }
resolution.values = append(resolution.values, value)
resolution.artifacts = append(resolution.artifacts, hydrated)
} }
} }
resolution.decision = decision
if output != nil { if output != nil {
recordCheckpointEvent(output, loader, string(stage), stepID, laneID, moduleKey, decision) recordCheckpointEvent(output, loader, string(stage), stepID, laneID, moduleKey, decision)
} }
if err := requireReusableCheckpoint(policy, stepID, laneID, decision); err != nil { if err := requireReusableCheckpoint(policy, stepID, laneID, decision); err != nil {
return decision, err return resolution, err
} }
return decision, nil return resolution, nil
} }
type SourceCheckpoint struct { type SourceCheckpoint struct {
@@ -296,19 +354,19 @@ func (noopCheckpointRecorder) NormalizeFailed(string, string, []CheckpointFinger
func (noopCheckpointLoader) Enabled() bool { return false } func (noopCheckpointLoader) Enabled() bool { return false }
func (noopCheckpointLoader) Source(string) (SourceCheckpoint, CheckpointDecision) { func (noopCheckpointLoader) Source(string) (SourceCheckpoint, CheckpointDecision) {
return SourceCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled, "checkpoint loading disabled") return SourceCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled)
} }
func (noopCheckpointLoader) Extract(string, string, []CheckpointFingerprint) (ExtractCheckpoint, CheckpointDecision) { func (noopCheckpointLoader) Extract(string, string, []CheckpointFingerprint) (ExtractCheckpoint, CheckpointDecision) {
return ExtractCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled, "checkpoint loading disabled") return ExtractCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled)
} }
func (noopCheckpointLoader) Merge(string, string, []CheckpointFingerprint) (MergeCheckpoint, CheckpointDecision) { func (noopCheckpointLoader) Merge(string, string, []CheckpointFingerprint) (MergeCheckpoint, CheckpointDecision) {
return MergeCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled, "checkpoint loading disabled") return MergeCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled)
} }
func (noopCheckpointLoader) Normalize(string, string, []CheckpointFingerprint) (NormalizeCheckpoint, CheckpointDecision) { func (noopCheckpointLoader) Normalize(string, string, []CheckpointFingerprint) (NormalizeCheckpoint, CheckpointDecision) {
return NormalizeCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled, "checkpoint loading disabled") return NormalizeCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled)
} }
func (noopCheckpointLoader) AcceptedNormalize(string, string, string) (NormalizeCheckpoint, CheckpointDecision) { func (noopCheckpointLoader) AcceptedNormalize(string, string, string) (NormalizeCheckpoint, CheckpointDecision) {
return NormalizeCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled, "checkpoint loading disabled") return NormalizeCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled)
} }
func checkpointExtractRunning(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint) error { func checkpointExtractRunning(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint) error {

View File

@@ -33,13 +33,13 @@ func (l *acceptedCheckpointLoader) AcceptedNormalize(stepID, laneID, _ string) (
} }
func (l *acceptedCheckpointLoader) Extract(laneID string, _ string, dependencies []CheckpointFingerprint) (ExtractCheckpoint, CheckpointDecision) { func (l *acceptedCheckpointLoader) Extract(laneID string, _ string, dependencies []CheckpointFingerprint) (ExtractCheckpoint, CheckpointDecision) {
l.extractDeps[laneID] = append([]CheckpointFingerprint(nil), dependencies...) l.extractDeps[laneID] = append([]CheckpointFingerprint(nil), dependencies...)
return ExtractCheckpoint{}, NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing, "checkpoint missing") return ExtractCheckpoint{}, NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing)
} }
func (l *acceptedCheckpointLoader) Merge(_ string, _ string, _ []CheckpointFingerprint) (MergeCheckpoint, CheckpointDecision) { func (l *acceptedCheckpointLoader) Merge(_ string, _ string, _ []CheckpointFingerprint) (MergeCheckpoint, CheckpointDecision) {
return MergeCheckpoint{}, NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing, "checkpoint missing") return MergeCheckpoint{}, NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing)
} }
func (l *acceptedCheckpointLoader) Normalize(_ string, _ string, _ []CheckpointFingerprint) (NormalizeCheckpoint, CheckpointDecision) { func (l *acceptedCheckpointLoader) Normalize(_ string, _ string, _ []CheckpointFingerprint) (NormalizeCheckpoint, CheckpointDecision) {
return NormalizeCheckpoint{}, NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing, "checkpoint missing") return NormalizeCheckpoint{}, NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing)
} }
func TestRunnerHydratesRequiredNormalizedArtifact(t *testing.T) { func TestRunnerHydratesRequiredNormalizedArtifact(t *testing.T) {
@@ -85,7 +85,7 @@ func TestRunnerHydratesRequiredNormalizedArtifact(t *testing.T) {
producerKey := CheckpointLaneKey(producer.resolved.StepID, producer.resolved.ID) producerKey := CheckpointLaneKey(producer.resolved.StepID, producer.resolved.ID)
consumerKey := CheckpointLaneKey(consumer.resolved.StepID, consumer.resolved.ID) consumerKey := CheckpointLaneKey(consumer.resolved.StepID, consumer.resolved.ID)
loader.accepted[producerKey] = NormalizeCheckpoint{Output: stored, Warnings: []contracts.Warning{{Scope: "normalize", ReasonCode: "stored-warning", Message: "stored normalize warning"}}} loader.accepted[producerKey] = NormalizeCheckpoint{Output: stored, Warnings: []contracts.Warning{{Scope: "normalize", ReasonCode: "stored-warning", Message: "stored normalize warning"}}}
loader.acceptedDecision[producerKey] = NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused, "accepted artifact reusable") loader.acceptedDecision[producerKey] = NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused)
policy := CheckpointExecutionPolicy{ policy := CheckpointExecutionPolicy{
RequireReusableLanes: map[string]struct{}{producerKey: {}}, RequireReusableLanes: map[string]struct{}{producerKey: {}},
ForcedLanes: map[string]struct{}{consumerKey: {}}, ForcedLanes: map[string]struct{}{consumerKey: {}},
@@ -154,12 +154,12 @@ func TestRunnerRejectsInvalidRequiredNormalizedArtifactBeforeConsumer(t *testing
mutate func(*CheckpointArtifact) mutate func(*CheckpointArtifact)
wantCode CheckpointReasonCode wantCode CheckpointReasonCode
}{ }{
{"missing", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing, "checkpoint missing"), nil, CheckpointReasonMissing}, {"missing", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing), nil, CheckpointReasonMissing},
{"rejected status", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonStatusNotReusable, "status rejected"), nil, CheckpointReasonStatusNotReusable}, {"rejected status", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonStatusNotReusable), nil, CheckpointReasonStatusNotReusable},
{"corrupt payload", NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused, "accepted artifact reusable"), func(v *CheckpointArtifact) { v.Artifact.Content = []byte(`{"items":[`) }, CheckpointReasonArtifactPayloadInvalid}, {"corrupt payload", NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused), func(v *CheckpointArtifact) { v.Artifact.Content = []byte(`{"items":[`) }, CheckpointReasonArtifactPayloadInvalid},
{"non canonical", NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused, "accepted artifact reusable"), func(v *CheckpointArtifact) { v.Artifact.Content = []byte(`{"items": ["stored"]}`) }, CheckpointReasonArtifactNotCanonical}, {"non canonical", NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused), func(v *CheckpointArtifact) { v.Artifact.Content = []byte(`{"items": ["stored"]}`) }, CheckpointReasonArtifactNotCanonical},
{"wrong codec identity", NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused, "accepted artifact reusable"), func(v *CheckpointArtifact) { v.Artifact.Kind = "test/score" }, CheckpointReasonArtifactCodecIncompatible}, {"wrong codec identity", NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused), func(v *CheckpointArtifact) { v.Artifact.Kind = "test/score" }, CheckpointReasonArtifactCodecIncompatible},
{"wrong content digest", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonArtifactDigestMismatch, "content digest mismatch"), nil, CheckpointReasonArtifactDigestMismatch}, {"wrong content digest", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonArtifactDigestMismatch), nil, CheckpointReasonArtifactDigestMismatch},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -205,6 +205,100 @@ func TestRunnerRejectsInvalidRequiredNormalizedArtifactBeforeConsumer(t *testing
} }
} }
func TestRunnerRetainsEarlierHydratedProducerWhenLaterRequiredProducerFails(t *testing.T) {
prepared := preparedPipelineWithSharedProducerStep(t)
first := &prepared.Steps[0].lanes[0]
second := &prepared.Steps[0].lanes[1]
consumer := &prepared.Steps[1].lanes[0]
doc := prepared.input.(*typedTestInput).doc
stored, err := checkpointArtifact(first.typed.codec, first.resolved.ID, first.resolved.Normalize.Module, doc.ID, codecNotes{Items: []string{"retained producer"}})
if err != nil {
t.Fatal(err)
}
consumerCalls := 0
consumer.typed.extract = func(context.Context, any, contracts.TypedExtractionRequest) (erasedTypedResult, error) {
consumerCalls++
return erasedTypedResult{Value: codecScore{Value: 1}}, nil
}
loader := newAcceptedCheckpointLoader()
firstKey := CheckpointLaneKey(first.resolved.StepID, first.resolved.ID)
secondKey := CheckpointLaneKey(second.resolved.StepID, second.resolved.ID)
loader.accepted[firstKey] = NormalizeCheckpoint{Output: stored, Warnings: []contracts.Warning{{Scope: "normalize", ReasonCode: "retained-warning", Message: "retained warning"}}}
loader.acceptedDecision[firstKey] = NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused)
loader.acceptedDecision[secondKey] = NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing)
policy := CheckpointExecutionPolicy{RequireReusableLanes: map[string]struct{}{firstKey: {}, secondKey: {}}}
output, runErr := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Checkpoint: loader, CheckpointPolicy: policy})
if runErr == nil || !strings.Contains(runErr.Error(), string(CheckpointReasonMissing)) {
t.Fatalf("Run() error = %v, want missing required producer", runErr)
}
if consumerCalls != 0 {
t.Fatalf("consumer calls = %d, want zero", consumerCalls)
}
if len(output.NormalizeOutputs) != 1 || output.NormalizeOutputs[0].LaneID != first.resolved.ID || string(output.NormalizeOutputs[0].Artifact.Content) != string(stored.Artifact.Content) {
t.Fatalf("retained normalize outputs = %#v, want first producer", output.NormalizeOutputs)
}
if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != "retained-warning" {
t.Fatalf("retained warnings = %#v", output.Warnings)
}
type decisionExpectation struct {
step, lane string
category CheckpointDecisionCategory
reason CheckpointReasonCode
}
want := []decisionExpectation{
{first.resolved.StepID, first.resolved.ID, CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused},
{second.resolved.StepID, second.resolved.ID, CheckpointDecisionExecuted, CheckpointReasonMissing},
}
var got []decisionExpectation
for _, event := range output.CheckpointEvents {
if event.Stage == string(StageNormalize) {
got = append(got, decisionExpectation{event.StepID, event.LaneID, event.Category, event.ReasonCode})
}
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("normalize decisions = %#v, want %#v", got, want)
}
var manifestGot []decisionExpectation
for _, decision := range output.Manifest.CheckpointDecisions {
if decision.Stage == string(StageNormalize) {
manifestGot = append(manifestGot, decisionExpectation{
decision.StepID, decision.LaneID,
CheckpointDecisionCategory(decision.Category), CheckpointReasonCode(decision.ReasonCode),
})
}
}
if !reflect.DeepEqual(manifestGot, want) {
t.Fatalf("manifest normalize decisions = %#v, want %#v", manifestGot, want)
}
}
func preparedPipelineWithSharedProducerStep(t *testing.T) *PreparedPipeline {
t.Helper()
catalog := typedResolutionCatalog(t, completeTypedCatalogOptions())
base := typedResolutionProfile()
profile := base
profile.Artifacts = nil
profile.Steps = []PipelineStepProfile{
{ID: "producers", Artifacts: map[string]ArtifactLaneProfile{"first": base.Artifacts["notes"], "second": base.Artifacts["notes"]}},
{ID: "consumer", Artifacts: map[string]ArtifactLaneProfile{"score": base.Artifacts["score"]}},
}
resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
if err != nil {
t.Fatalf("ResolvePipeline() error = %v", err)
}
prepared, err := Prepare(resolved, registriesFromModuleCatalog(catalog), ModuleDependencies{})
if err != nil {
t.Fatalf("Prepare() error = %v", err)
}
doc := typedTestDocumentWithUnits(1)
prepared.input.(*typedTestInput).doc = doc
prepared.chunker = &typedTestChunker{key: "typed/chunk", plan: typedTestPlan(doc)}
return prepared
}
func TestForcedRequiredLaneExecutesInsteadOfHydrating(t *testing.T) { func TestForcedRequiredLaneExecutesInsteadOfHydrating(t *testing.T) {
prepared := preparedOrderedPipeline(t, 1, prepared := preparedOrderedPipeline(t, 1,
orderedLaneSpec{id: "unrelated", profile: "score"}, orderedLaneSpec{id: "unrelated", profile: "score"},
@@ -247,9 +341,9 @@ func TestForcedRequiredLaneExecutesInsteadOfHydrating(t *testing.T) {
producerKey := CheckpointLaneKey(producer.resolved.StepID, producer.resolved.ID) producerKey := CheckpointLaneKey(producer.resolved.StepID, producer.resolved.ID)
consumerKey := CheckpointLaneKey(consumer.resolved.StepID, consumer.resolved.ID) consumerKey := CheckpointLaneKey(consumer.resolved.StepID, consumer.resolved.ID)
loader.accepted[unrelatedKey] = NormalizeCheckpoint{Output: unrelatedArtifact} loader.accepted[unrelatedKey] = NormalizeCheckpoint{Output: unrelatedArtifact}
loader.acceptedDecision[unrelatedKey] = NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused, "accepted artifact reusable") loader.acceptedDecision[unrelatedKey] = NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused)
loader.accepted[producerKey] = NormalizeCheckpoint{Output: producerArtifact} loader.accepted[producerKey] = NormalizeCheckpoint{Output: producerArtifact}
loader.acceptedDecision[producerKey] = NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused, "accepted artifact reusable") loader.acceptedDecision[producerKey] = NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused)
policy := CheckpointExecutionPolicy{ policy := CheckpointExecutionPolicy{
ForcedLanes: map[string]struct{}{producerKey: {}, consumerKey: {}}, ForcedLanes: map[string]struct{}{producerKey: {}, consumerKey: {}},
RequireReusableLanes: map[string]struct{}{unrelatedKey: {}, producerKey: {}}, RequireReusableLanes: map[string]struct{}{unrelatedKey: {}, producerKey: {}},

View File

@@ -81,9 +81,15 @@ func (r *Runner) runLanes(parent context.Context, input RunInput, step PreparedP
output := RunOutput{Manifest: manifestFromPipeline(input)} output := RunOutput{Manifest: manifestFromPipeline(input)}
states, err := initializeLaneStates(input, step, checkpoints, loader, doc, chunks, &output) states, err := initializeLaneStates(input, step, checkpoints, loader, doc, chunks, &output)
if err != nil { if err != nil {
if mergeErr := mergeTerminalLaneStates(&output, states); mergeErr != nil {
return output, errors.Join(err, mergeErr)
}
return output, err return output, err
} }
completedOutputs, runErrors := r.runLaneEngine(parent, input, checkpoints, loader, doc, sourceInput, sessionID, chunks, states) completedOutputs, runErrors := r.runLaneEngine(parent, laneEngineConfig{
input: input, checkpoints: checkpoints, loader: loader, doc: doc,
sourceInput: sourceInput, sessionID: sessionID, chunks: chunks, states: states,
})
if err := mergeCompletedLanes(&output, completedOutputs); err != nil { if err := mergeCompletedLanes(&output, completedOutputs); err != nil {
return output, err return output, err
} }
@@ -97,183 +103,232 @@ func initializeLaneStates(input RunInput, step PreparedPipelineStep, checkpoints
states := make([]*laneExtractState, len(step.lanes)) states := make([]*laneExtractState, len(step.lanes))
for i, prepared := range step.lanes { for i, prepared := range step.lanes {
if prepared.typed == nil { if prepared.typed == nil {
return nil, fmt.Errorf("typed lane %q executor is not prepared", prepared.resolved.ID) return states, fmt.Errorf("typed lane %q executor is not prepared", prepared.resolved.ID)
} }
if err := setTypedLaneManifestMetadata(output, prepared.resolved.ID, prepared.typed.extractor, prepared.typed.merger, prepared.typed.normalizer); err != nil { if err := setTypedLaneManifestMetadata(output, prepared.resolved.ID, prepared.typed.extractor, prepared.typed.merger, prepared.typed.normalizer); err != nil {
return nil, err return states, err
} }
if input.CheckpointPolicy.requiresReusable(input.stepID, prepared.resolved.ID) && !input.CheckpointPolicy.forced(input.stepID, prepared.resolved.ID) { if input.CheckpointPolicy.requiresReusable(input.stepID, prepared.resolved.ID) && !input.CheckpointPolicy.forced(input.stepID, prepared.resolved.ID) {
state, err := hydrateRequiredLane(input, loader, doc, i, prepared, output) state, err := hydrateRequiredLane(input, loader, doc, i, prepared)
if err != nil {
return nil, err
}
states[i] = state states[i] = state
if err != nil {
return states, err
}
continue continue
} }
state, err := prepareLaneExtract(input, loader, doc, chunks, i, prepared, output) state, err := prepareLaneExtract(input, loader, doc, chunks, i, prepared, output)
if err != nil { if err != nil {
return nil, err return states, err
} }
if !state.decision.Reused { if !state.decision.Reused {
if err := checkpointExtractRunning(checkpoints, input.stepID, prepared.resolved.ID, prepared.resolved.Extract.Module, state.deps); err != nil { if err := checkpointExtractRunning(checkpoints, input.stepID, prepared.resolved.ID, prepared.resolved.Extract.Module, state.deps); err != nil {
return nil, fmt.Errorf("write extract checkpoint for lane %q: %w", prepared.resolved.ID, err) return states, fmt.Errorf("write extract checkpoint for lane %q: %w", prepared.resolved.ID, err)
} }
} else if err := finalizeLaneExtract(checkpoints, input.stepID, state); err != nil { } else if err := finalizeLaneExtract(checkpoints, input.stepID, state); err != nil {
return nil, err return states, err
} }
states[i] = state states[i] = state
} }
return states, nil return states, nil
} }
func (r *Runner) runLaneEngine(parent context.Context, input RunInput, checkpoints CheckpointRecorder, loader CheckpointLoader, doc *source.SourceDocument, sourceInput contracts.LLMInputMaterial, sessionID string, chunks []source.Chunk, states []*laneExtractState) ([]RunOutput, []orderedRunError) { type laneEngineConfig struct {
workerCount := input.ExtractWorkers input RunInput
checkpoints CheckpointRecorder
loader CheckpointLoader
doc *source.SourceDocument
sourceInput contracts.LLMInputMaterial
sessionID string
chunks []source.Chunk
states []*laneExtractState
}
type laneEngine struct {
runner *Runner
laneEngineConfig
ctx context.Context
cancel context.CancelFunc
workerCount int
jobs chan extractJob
results chan extractJobResult
completions chan laneCompletion
continuations chan *laneExtractState
continuationWorkers sync.WaitGroup
pending []*laneExtractState
completedOutputs []RunOutput
runErrors []orderedRunError
launched int
completed int
}
func (r *Runner) runLaneEngine(parent context.Context, config laneEngineConfig) ([]RunOutput, []orderedRunError) {
engine := newLaneEngine(r, parent, config)
return engine.run()
}
func newLaneEngine(r *Runner, parent context.Context, config laneEngineConfig) *laneEngine {
workerCount := config.input.ExtractWorkers
if workerCount < 1 { if workerCount < 1 {
workerCount = 1 workerCount = 1
} }
ctx, cancel := context.WithCancel(parent) ctx, cancel := context.WithCancel(parent)
defer cancel() return &laneEngine{
jobs := make(chan extractJob, workerCount) runner: r, laneEngineConfig: config, ctx: ctx, cancel: cancel, workerCount: workerCount,
results := make(chan extractJobResult, workerCount) jobs: make(chan extractJob, workerCount), results: make(chan extractJobResult, workerCount),
completions := make(chan laneCompletion, len(states)) completions: make(chan laneCompletion, len(config.states)), continuations: make(chan *laneExtractState, workerCount),
continuations := make(chan *laneExtractState, workerCount) completedOutputs: make([]RunOutput, len(config.states)),
}
}
func (e *laneEngine) run() ([]RunOutput, []orderedRunError) {
defer e.cancel()
e.initializeCollection()
e.startExtractWorkers()
e.startContinuationWorkers()
e.collect()
close(e.continuations)
e.continuationWorkers.Wait()
return e.completedOutputs, e.runErrors
}
func (e *laneEngine) initializeCollection() {
for _, state := range e.states {
if state.terminal {
e.completedOutputs[state.index] = state.output
} else if state.decision.Reused {
e.pending = append(e.pending, state)
}
}
}
func (e *laneEngine) startExtractWorkers() {
var workers sync.WaitGroup var workers sync.WaitGroup
for i := 0; i < workerCount; i++ { for i := 0; i < e.workerCount; i++ {
workers.Add(1) workers.Add(1)
go func() { go func() {
defer workers.Done() defer workers.Done()
for job := range jobs { for job := range e.jobs {
if ctx.Err() != nil { if e.ctx.Err() != nil {
continue continue
} }
result := r.runExtractJob(ctx, input, doc, sourceInput, sessionID, job) e.results <- e.runner.runExtractJob(e.ctx, e.input, e.doc, e.sourceInput, e.sessionID, job)
results <- result
} }
}() }()
} }
go func() { go e.dispatchExtractJobs()
defer close(jobs) go func() { workers.Wait(); close(e.results) }()
for chunkIndex := range chunks { }
for laneIndex := range states {
state := states[laneIndex] func (e *laneEngine) dispatchExtractJobs() {
defer close(e.jobs)
for chunkIndex := range e.chunks {
for laneIndex := range e.states {
state := e.states[laneIndex]
if state.terminal || state.decision.Reused { if state.terminal || state.decision.Reused {
continue continue
} }
select { select {
case jobs <- extractJob{lane: state, chunk: chunks[chunkIndex]}: case e.jobs <- extractJob{lane: state, chunk: e.chunks[chunkIndex]}:
case <-ctx.Done(): case <-e.ctx.Done():
return return
} }
} }
} }
}() }
go func() { workers.Wait(); close(results) }()
var continuationWorkers sync.WaitGroup func (e *laneEngine) startContinuationWorkers() {
for i := 0; i < workerCount; i++ { for i := 0; i < e.workerCount; i++ {
continuationWorkers.Add(1) e.continuationWorkers.Add(1)
go func() { go func() {
defer continuationWorkers.Done() defer e.continuationWorkers.Done()
for state := range continuations { for state := range e.continuations {
if err := ctx.Err(); err != nil { if err := e.ctx.Err(); err != nil {
completions <- laneCompletion{index: state.index, err: err} e.completions <- laneCompletion{index: state.index, err: err}
continue continue
} }
laneOutput, err := r.continueLane(ctx, input, checkpoints, loader, doc, sourceInput, sessionID, chunks, state) laneOutput, err := e.runner.continueLane(e.ctx, e.input, e.checkpoints, e.loader, e.doc, e.sourceInput, e.sessionID, e.chunks, state)
completions <- laneCompletion{index: state.index, output: laneOutput, err: err} e.completions <- laneCompletion{index: state.index, output: laneOutput, err: err}
} }
}() }()
} }
completedOutputs, runErrors := collectLaneResults(ctx, cancel, input, checkpoints, chunks, states, results, completions, continuations)
close(continuations)
continuationWorkers.Wait()
return completedOutputs, runErrors
} }
func collectLaneResults(ctx context.Context, cancel context.CancelFunc, input RunInput, checkpoints CheckpointRecorder, chunks []source.Chunk, states []*laneExtractState, results <-chan extractJobResult, completions <-chan laneCompletion, continuations chan<- *laneExtractState) ([]RunOutput, []orderedRunError) { func (e *laneEngine) collect() {
completedOutputs := make([]RunOutput, len(states)) resultChannel := (<-chan extractJobResult)(e.results)
var runErrors []orderedRunError for resultChannel != nil || len(e.pending) > 0 || e.completed < e.launched {
var pendingContinuations []*laneExtractState
launched, completed := 0, 0
for _, state := range states {
if state.terminal {
completedOutputs[state.index] = state.output
} else if state.decision.Reused {
pendingContinuations = append(pendingContinuations, state)
}
}
resultChannel := results
for resultChannel != nil || len(pendingContinuations) > 0 || completed < launched {
var continuationChannel chan<- *laneExtractState var continuationChannel chan<- *laneExtractState
var nextContinuation *laneExtractState var nextContinuation *laneExtractState
if len(pendingContinuations) > 0 && ctx.Err() == nil { if len(e.pending) > 0 && e.ctx.Err() == nil {
continuationChannel = continuations continuationChannel = e.continuations
nextContinuation = pendingContinuations[0] nextContinuation = e.pending[0]
} else if ctx.Err() != nil { } else if e.ctx.Err() != nil {
pendingContinuations = nil e.pending = nil
} }
select { select {
case continuationChannel <- nextContinuation: case continuationChannel <- nextContinuation:
pendingContinuations = pendingContinuations[1:] e.pending = e.pending[1:]
launched++ e.launched++
case result, ok := <-resultChannel: case result, ok := <-resultChannel:
if !ok { if !ok {
resultChannel = nil resultChannel = nil
continue continue
} }
state := states[result.laneIndex] e.handleExtractResult(result)
case completion := <-e.completions:
e.handleCompletion(completion)
}
}
}
func (e *laneEngine) handleExtractResult(result extractJobResult) {
state := e.states[result.laneIndex]
state.remaining-- state.remaining--
if result.err != nil { if result.err != nil {
state.failed = true state.failed = true
runErrors = append(runErrors, orderedRunError{stage: 0, lane: state.index, chunk: result.chunkIndex, err: result.err}) e.runErrors = append(e.runErrors, orderedRunError{stage: 0, lane: state.index, chunk: result.chunkIndex, err: result.err})
_ = checkpointExtractFailed(checkpoints, input.stepID, state.prepared.resolved.ID, state.prepared.resolved.Extract.Module, state.deps, result.err) _ = checkpointExtractFailed(e.checkpoints, e.input.stepID, state.prepared.resolved.ID, state.prepared.resolved.Extract.Module, state.deps, result.err)
cancel() e.cancel()
} else { } else {
state.results[result.chunkIndex] = result state.results[result.chunkIndex] = result
} }
if state.remaining == 0 && !state.failed && ctx.Err() == nil { if state.remaining != 0 || state.failed || e.ctx.Err() != nil {
if err := finalizeLaneExtract(checkpoints, input.stepID, state); err != nil { return
}
if err := finalizeLaneExtract(e.checkpoints, e.input.stepID, state); err != nil {
state.failed = true state.failed = true
runErrors = append(runErrors, orderedRunError{stage: 0, lane: state.index, chunk: len(chunks), err: err}) e.runErrors = append(e.runErrors, orderedRunError{stage: 0, lane: state.index, chunk: len(e.chunks), err: err})
cancel() e.cancel()
} else { return
pendingContinuations = append(pendingContinuations, state)
} }
} e.pending = append(e.pending, state)
case completion := <-completions:
completed++
completedOutputs[completion.index] = completion.output
if completion.err != nil {
runErrors = append(runErrors, classifyLaneError(completion.index, len(chunks), completion.err))
cancel()
}
}
}
return completedOutputs, runErrors
} }
func hydrateRequiredLane(input RunInput, loader CheckpointLoader, doc *source.SourceDocument, index int, prepared preparedLaneExecutor, output *RunOutput) (*laneExtractState, error) { func (e *laneEngine) handleCompletion(completion laneCompletion) {
e.completed++
e.completedOutputs[completion.index] = completion.output
if completion.err != nil {
e.runErrors = append(e.runErrors, classifyLaneError(completion.index, len(e.chunks), completion.err))
e.cancel()
}
}
func hydrateRequiredLane(input RunInput, loader CheckpointLoader, doc *source.SourceDocument, index int, prepared preparedLaneExecutor) (*laneExtractState, error) {
lane, typed := prepared.resolved, prepared.typed lane, typed := prepared.resolved, prepared.typed
local := RunOutput{Manifest: manifestFromPipeline(input)} local := RunOutput{Manifest: manifestFromPipeline(input)}
checkpoint, decision := loader.AcceptedNormalize(input.stepID, lane.ID, lane.Normalize.Module) checkpoint, decision := loader.AcceptedNormalize(input.stepID, lane.ID, lane.Normalize.Module)
if decision.Reused { if decision.Reused {
decision = checkpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused, "accepted normalized artifact is reusable") decision = checkpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused)
if checkpoint.Output.LaneID != lane.ID || checkpoint.Output.ModuleKey != lane.Normalize.Module || checkpoint.Output.SourceID != doc.ID { if checkpoint.Output.LaneID != lane.ID || checkpoint.Output.ModuleKey != lane.Normalize.Module || checkpoint.Output.SourceID != doc.ID {
decision = checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonArtifactPayloadInvalid, "accepted normalized artifact provenance does not match the producer lane") decision = checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonArtifactPayloadInvalid)
} }
} }
decision, err := resolveCheckpointDecision(&local, loader, input.CheckpointPolicy, StageNormalize, input.stepID, lane.ID, lane.Normalize.Module, decision, typed.codec, []CheckpointArtifact{checkpoint.Output}) resolution, err := resolveCheckpointDecision(&local, loader, input.CheckpointPolicy, StageNormalize, input.stepID, lane.ID, lane.Normalize.Module, decision, typed.codec, []CheckpointArtifact{checkpoint.Output})
decision = resolution.decision
state := &laneExtractState{index: index, prepared: prepared, decision: decision, terminal: true, output: local}
if err != nil { if err != nil {
if mergeErr := mergeLaneOutput(output, local); mergeErr != nil { return state, err
return nil, mergeErr
}
return nil, err
}
_, hydrated, err := decodeCanonicalCheckpointArtifact(typed.codec, checkpoint.Output)
if err != nil {
return nil, fmt.Errorf("hydrate accepted normalized artifact for step %q lane %q: %w", input.stepID, lane.ID, err)
} }
hydrated := resolution.artifacts[0]
local.Warnings = append(local.Warnings, cloneWarnings(checkpoint.Warnings)...) local.Warnings = append(local.Warnings, cloneWarnings(checkpoint.Warnings)...)
local.NormalizeOutputs = append(local.NormalizeOutputs, contracts.SerializedOutput{ local.NormalizeOutputs = append(local.NormalizeOutputs, contracts.SerializedOutput{
StepID: input.stepID, StepID: input.stepID,
@@ -282,7 +337,20 @@ func hydrateRequiredLane(input RunInput, loader CheckpointLoader, doc *source.So
SourceID: doc.ID, SourceID: doc.ID,
Artifact: contracts.CloneSerializedArtifact(hydrated.Artifact), Artifact: contracts.CloneSerializedArtifact(hydrated.Artifact),
}) })
return &laneExtractState{index: index, prepared: prepared, decision: decision, terminal: true, output: local}, nil state.output = local
return state, nil
}
func mergeTerminalLaneStates(output *RunOutput, states []*laneExtractState) error {
for _, state := range states {
if state == nil || !state.terminal {
continue
}
if err := mergeLaneOutput(output, state.output); err != nil {
return err
}
}
return nil
} }
func mergeCompletedLanes(output *RunOutput, completedOutputs []RunOutput) error { func mergeCompletedLanes(output *RunOutput, completedOutputs []RunOutput) error {
@@ -304,19 +372,16 @@ func prepareLaneExtract(input RunInput, loader CheckpointLoader, doc *source.Sou
deps := append(digestFingerprints("chunks", digest), generatedReferenceDependencies(extractReferences)...) deps := append(digestFingerprints("chunks", digest), generatedReferenceDependencies(extractReferences)...)
state := &laneExtractState{index: index, prepared: prepared, deps: normalizeCheckpointFingerprints(deps), remaining: len(chunks), results: make(map[int]extractJobResult, len(chunks))} state := &laneExtractState{index: index, prepared: prepared, deps: normalizeCheckpointFingerprints(deps), remaining: len(chunks), results: make(map[int]extractJobResult, len(chunks))}
cp, decision := loadExtract(loader, input.stepID, lane.ID, lane.Extract.Module, state.deps) cp, decision := loadExtract(loader, input.stepID, lane.ID, lane.Extract.Module, state.deps)
decision, err = resolveCheckpointDecision(output, loader, input.CheckpointPolicy, StageExtract, input.stepID, lane.ID, lane.Extract.Module, decision, typed.codec, cp.Outputs) resolution, err := resolveCheckpointDecision(output, loader, input.CheckpointPolicy, StageExtract, input.stepID, lane.ID, lane.Extract.Module, decision, typed.codec, cp.Outputs)
if err != nil { if err != nil {
return nil, err return nil, err
} }
decision = resolution.decision
state.decision = decision state.decision = decision
if decision.Reused { if decision.Reused {
state.remaining = 0 state.remaining = 0
for _, stored := range cp.Outputs { for i, stored := range resolution.artifacts {
value, hydrated, decodeErr := decodeCanonicalCheckpointArtifact(typed.codec, stored) value := resolution.values[i]
if decodeErr != nil {
return nil, fmt.Errorf("decode extract checkpoint for lane %q: %w", lane.ID, decodeErr)
}
stored = hydrated
artifact := erasedExtractArtifact{LaneID: lane.ID, ExtractorKey: lane.Extract.Module, SourceID: doc.ID, ChunkID: stored.ChunkID, ChunkIndex: stored.ChunkIndex, ChunkRef: stored.ChunkRef, Value: value} artifact := erasedExtractArtifact{LaneID: lane.ID, ExtractorKey: lane.Extract.Module, SourceID: doc.ID, ChunkID: stored.ChunkID, ChunkIndex: stored.ChunkIndex, ChunkRef: stored.ChunkRef, Value: value}
if stored.ChunkIndex >= 0 && stored.ChunkIndex < len(chunks) && artifact.ChunkRef == (source.SourceRef{}) { if stored.ChunkIndex >= 0 && stored.ChunkIndex < len(chunks) && artifact.ChunkRef == (source.SourceRef{}) {
artifact.ChunkRef = chunks[stored.ChunkIndex].Ref artifact.ChunkRef = chunks[stored.ChunkIndex].Ref

View File

@@ -227,10 +227,11 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints
mergeDeps := append(artifactCheckpointDigests(extracts.serialized), generatedReferenceDependencies(mergeReferences)...) mergeDeps := append(artifactCheckpointDigests(extracts.serialized), generatedReferenceDependencies(mergeReferences)...)
mergeDeps = normalizeCheckpointFingerprints(mergeDeps) mergeDeps = normalizeCheckpointFingerprints(mergeDeps)
mergeCP, mergeDecision := loadMerge(loader, input.stepID, lane.ID, lane.Merge.Module, mergeDeps) mergeCP, mergeDecision := loadMerge(loader, input.stepID, lane.ID, lane.Merge.Module, mergeDeps)
mergeDecision, err := resolveCheckpointDecision(output, loader, input.CheckpointPolicy, StageMerge, input.stepID, lane.ID, lane.Merge.Module, mergeDecision, typed.codec, []CheckpointArtifact{mergeCP.Output}) mergeResolution, err := resolveCheckpointDecision(output, loader, input.CheckpointPolicy, StageMerge, input.stepID, lane.ID, lane.Merge.Module, mergeDecision, typed.codec, []CheckpointArtifact{mergeCP.Output})
if err != nil { if err != nil {
return stageResult, err return stageResult, err
} }
mergeDecision = mergeResolution.decision
if err := writeDebugTimed(input.Debug, path.Join("merge", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "decision": mergeDecision, "source": debugSourceDocumentEnvelope(doc), "extract_outputs": debugCheckpointArtifacts(extracts.serialized), "options": redactSensitiveMap(lane.Merge.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil { if err := writeDebugTimed(input.Debug, path.Join("merge", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "decision": mergeDecision, "source": debugSourceDocumentEnvelope(doc), "extract_outputs": debugCheckpointArtifacts(extracts.serialized), "options": redactSensitiveMap(lane.Merge.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
return stageResult, err return stageResult, err
} }
@@ -238,12 +239,8 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints
var serializedMerge CheckpointArtifact var serializedMerge CheckpointArtifact
var mergeWarnings []contracts.Warning var mergeWarnings []contracts.Warning
if mergeDecision.Reused { if mergeDecision.Reused {
value, hydrated, decodeErr := decodeCanonicalCheckpointArtifact(typed.codec, mergeCP.Output) merged = erasedMergeArtifact{LaneID: lane.ID, MergerKey: lane.Merge.Module, SourceID: doc.ID, Value: mergeResolution.values[0]}
if decodeErr != nil { serializedMerge = mergeResolution.artifacts[0]
return stageResult, fmt.Errorf("decode merge checkpoint for lane %q: %w", lane.ID, decodeErr)
}
merged = erasedMergeArtifact{LaneID: lane.ID, MergerKey: lane.Merge.Module, SourceID: doc.ID, Value: value}
serializedMerge = hydrated
mergeWarnings = cloneWarnings(mergeCP.Warnings) mergeWarnings = cloneWarnings(mergeCP.Warnings)
output.Warnings = append(output.Warnings, mergeWarnings...) output.Warnings = append(output.Warnings, mergeWarnings...)
} else { } else {
@@ -327,21 +324,18 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
normalizeDeps := append(artifactCheckpointDigests([]CheckpointArtifact{serializedMerge}), generatedReferenceDependencies(normalizeReferences)...) normalizeDeps := append(artifactCheckpointDigests([]CheckpointArtifact{serializedMerge}), generatedReferenceDependencies(normalizeReferences)...)
normalizeDeps = normalizeCheckpointFingerprints(normalizeDeps) normalizeDeps = normalizeCheckpointFingerprints(normalizeDeps)
normalizeCP, normalizeDecision := loadNormalize(loader, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps) normalizeCP, normalizeDecision := loadNormalize(loader, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps)
normalizeDecision, err := resolveCheckpointDecision(output, loader, input.CheckpointPolicy, StageNormalize, input.stepID, lane.ID, lane.Normalize.Module, normalizeDecision, typed.codec, []CheckpointArtifact{normalizeCP.Output}) normalizeResolution, err := resolveCheckpointDecision(output, loader, input.CheckpointPolicy, StageNormalize, input.stepID, lane.ID, lane.Normalize.Module, normalizeDecision, typed.codec, []CheckpointArtifact{normalizeCP.Output})
if err != nil { if err != nil {
return stageResult, err return stageResult, err
} }
normalizeDecision = normalizeResolution.decision
if err := writeDebugTimed(input.Debug, path.Join("normalize", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "decision": normalizeDecision, "source": debugSourceDocumentEnvelope(doc), "merge_output": debugCheckpointArtifact(serializedMerge), "options": redactSensitiveMap(lane.Normalize.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil { if err := writeDebugTimed(input.Debug, path.Join("normalize", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "decision": normalizeDecision, "source": debugSourceDocumentEnvelope(doc), "merge_output": debugCheckpointArtifact(serializedMerge), "options": redactSensitiveMap(lane.Normalize.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
return stageResult, err return stageResult, err
} }
var serializedNormalize CheckpointArtifact var serializedNormalize CheckpointArtifact
var normalizeWarnings []contracts.Warning var normalizeWarnings []contracts.Warning
if normalizeDecision.Reused { if normalizeDecision.Reused {
_, hydrated, decodeErr := decodeCanonicalCheckpointArtifact(typed.codec, normalizeCP.Output) serializedNormalize = normalizeResolution.artifacts[0]
if decodeErr != nil {
return stageResult, fmt.Errorf("decode normalize checkpoint for lane %q: %w", lane.ID, decodeErr)
}
serializedNormalize = hydrated
normalizeWarnings = cloneWarnings(normalizeCP.Warnings) normalizeWarnings = cloneWarnings(normalizeCP.Warnings)
output.Warnings = append(output.Warnings, normalizeWarnings...) output.Warnings = append(output.Warnings, normalizeWarnings...)
} else { } else {

View File

@@ -28,7 +28,6 @@ func (l requiredCheckpointLoader) AcceptedNormalize(string, string, string) (Nor
} }
func TestRequiredCheckpointFailureRetainsDecision(t *testing.T) { func TestRequiredCheckpointFailureRetainsDecision(t *testing.T) {
const unsafeDetail = "unsafe-loader-detail-/private/checkpoint/path"
tests := []struct { tests := []struct {
name string name string
decision CheckpointDecision decision CheckpointDecision
@@ -36,10 +35,10 @@ func TestRequiredCheckpointFailureRetainsDecision(t *testing.T) {
wantCode CheckpointReasonCode wantCode CheckpointReasonCode
wantAction CheckpointDecisionCategory wantAction CheckpointDecisionCategory
}{ }{
{"missing", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing, unsafeDetail), false, CheckpointReasonMissing, CheckpointDecisionExecuted}, {"missing", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing), false, CheckpointReasonMissing, CheckpointDecisionExecuted},
{"corrupt", NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonReused, "checkpoint reusable"), true, CheckpointReasonArtifactNotCanonical, CheckpointDecisionExecuted}, {"corrupt", NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonReused), true, CheckpointReasonArtifactNotCanonical, CheckpointDecisionExecuted},
{"incompatible", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonArtifactCodecIncompatible, unsafeDetail), false, CheckpointReasonArtifactCodecIncompatible, CheckpointDecisionExecuted}, {"incompatible", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonArtifactCodecIncompatible), false, CheckpointReasonArtifactCodecIncompatible, CheckpointDecisionExecuted},
{"dependency invalidated", NewCheckpointDecision(CheckpointDecisionDependencyInvalidated, CheckpointReasonDependencyMismatch, unsafeDetail), false, CheckpointReasonDependencyMismatch, CheckpointDecisionDependencyInvalidated}, {"dependency invalidated", NewCheckpointDecision(CheckpointDecisionDependencyInvalidated, CheckpointReasonDependencyMismatch), false, CheckpointReasonDependencyMismatch, CheckpointDecisionDependencyInvalidated},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -62,9 +61,6 @@ func TestRequiredCheckpointFailureRetainsDecision(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), step.ID) || !strings.Contains(err.Error(), lane.resolved.ID) || !strings.Contains(err.Error(), string(test.wantCode)) { if err == nil || !strings.Contains(err.Error(), step.ID) || !strings.Contains(err.Error(), lane.resolved.ID) || !strings.Contains(err.Error(), string(test.wantCode)) {
t.Fatalf("Run() error = %v, want step, lane, and reason code %q", err, test.wantCode) t.Fatalf("Run() error = %v, want step, lane, and reason code %q", err, test.wantCode)
} }
if strings.Contains(err.Error(), unsafeDetail) {
t.Fatalf("Run() error leaked loader detail: %v", err)
}
var found bool var found bool
for _, event := range output.CheckpointEvents { for _, event := range output.CheckpointEvents {
if event.Stage == string(StageNormalize) && event.StepID == step.ID && event.LaneID == lane.resolved.ID { if event.Stage == string(StageNormalize) && event.StepID == step.ID && event.LaneID == lane.resolved.ID {
@@ -72,8 +68,8 @@ func TestRequiredCheckpointFailureRetainsDecision(t *testing.T) {
if event.Action != test.wantAction || event.ReasonCode != test.wantCode { if event.Action != test.wantAction || event.ReasonCode != test.wantCode {
t.Fatalf("checkpoint event = %#v, want action %q and reason %q", event, test.wantAction, test.wantCode) t.Fatalf("checkpoint event = %#v, want action %q and reason %q", event, test.wantAction, test.wantCode)
} }
if strings.Contains(event.Detail, unsafeDetail) { if event.Detail == "" || event.Detail != event.Reason {
t.Fatalf("checkpoint event leaked loader detail: %#v", event) t.Fatalf("checkpoint event detail is not code-owned compatibility text: %#v", event)
} }
} }
} }
@@ -97,6 +93,17 @@ func TestRequiredCheckpointFailureRetainsDecision(t *testing.T) {
} }
} }
func TestCheckpointDecisionDetailDoesNotCopyUnknownReasonCode(t *testing.T) {
const sentinel = "opaque-sensitive-value-78421"
decision := NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonCode(sentinel))
if decision.Detail == "" || decision.Detail != decision.Reason {
t.Fatalf("decision detail is not bounded compatibility text: %#v", decision)
}
if strings.Contains(decision.Detail, sentinel) || strings.Contains(decision.Reason, sentinel) {
t.Fatalf("decision detail copied caller-controlled reason code: %#v", decision)
}
}
func TestDecodeCheckpointArtifactRejectsIncompatibleCodecIdentityAndBytes(t *testing.T) { func TestDecodeCheckpointArtifactRejectsIncompatibleCodecIdentityAndBytes(t *testing.T) {
registry := NewArtifactCodecRegistry() registry := NewArtifactCodecRegistry()
if err := RegisterArtifactCodec(registry, notesCodec()); err != nil { if err := RegisterArtifactCodec(registry, notesCodec()); err != nil {

View File

@@ -10,7 +10,7 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"], "required": ["actor", "turn_kind", "source_refs"],
"properties": { "properties": {
"actor": { "actor": {
"type": "string", "type": "string",
@@ -20,44 +20,6 @@
"type": "string", "type": "string",
"enum": ["turn", "reaction", "legendary_action", "lair_action", "other"] "enum": ["turn", "reaction", "legendary_action", "lair_action", "other"]
}, },
"round": {
"type": ["integer", "null"],
"minimum": 1
},
"actions": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["category", "declaration", "targets", "resolution"],
"properties": {
"category": {
"type": "string",
"enum": ["attack", "spell", "movement", "item", "ability_check", "saving_throw", "condition", "other"]
},
"declaration": {
"type": "string",
"minLength": 1
},
"targets": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"resolution": {
"type": ["string", "null"],
"minLength": 1
}
}
}
},
"summary": {
"type": "string",
"minLength": 1
},
"source_refs": { "source_refs": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 1,

View File

@@ -8,7 +8,6 @@ import (
"io" "io"
"strings" "strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
) )
@@ -79,107 +78,21 @@ func (c *Codec) Decode(content []byte) (dnd.CombatTurnList, error) {
} }
// DecodeCandidate reads one strict durable JSON value before semantic // DecodeCandidate reads one strict durable JSON value before semantic
// validators have approved it. Nullable round and resolution fields are // validators have approved it.
// decoded through raw values so a missing key is not confused with null.
func (c *Codec) DecodeCandidate(content []byte) (dnd.CombatTurnList, error) { func (c *Codec) DecodeCandidate(content []byte) (dnd.CombatTurnList, error) {
decoder := json.NewDecoder(bytes.NewReader(content)) decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields() decoder.DisallowUnknownFields()
var wire combatTurnListWire var value dnd.CombatTurnList
if err := decoder.Decode(&wire); err != nil { if err := decoder.Decode(&value); err != nil {
return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: %w", err) return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: %w", err)
} }
var trailing any var trailing any
if err := decoder.Decode(&trailing); err != io.EOF { if err := decoder.Decode(&trailing); err != io.EOF {
return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: multiple JSON values") return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: multiple JSON values")
} }
value := dnd.CombatTurnList{CombatTurns: make([]dnd.CombatTurn, len(wire.CombatTurns))}
if wire.CombatTurns == nil {
value.CombatTurns = nil
}
for index, turn := range wire.CombatTurns {
round, err := decodeNullableInt(turn.Round, fmt.Sprintf("combat_turns[%d].round", index))
if err != nil {
return dnd.CombatTurnList{}, err
}
actions := make([]dnd.CombatAction, len(turn.Actions))
if turn.Actions == nil {
actions = nil
}
for actionIndex, action := range turn.Actions {
resolution, err := decodeNullableString(action.Resolution, fmt.Sprintf("combat_turns[%d].actions[%d].resolution", index, actionIndex))
if err != nil {
return dnd.CombatTurnList{}, err
}
actions[actionIndex] = dnd.CombatAction{
Category: action.Category,
Declaration: action.Declaration,
Targets: action.Targets,
Resolution: resolution,
}
}
value.CombatTurns[index] = dnd.CombatTurn{
Actor: turn.Actor,
TurnKind: turn.TurnKind,
Round: round,
Actions: actions,
Summary: turn.Summary,
SourceRefs: turn.SourceRefs,
}
}
return value, nil return value, nil
} }
type combatTurnListWire struct {
CombatTurns []combatTurnWire `json:"combat_turns"`
}
type combatTurnWire struct {
Actor string `json:"actor"`
TurnKind dnd.CombatTurnKind `json:"turn_kind"`
Round json.RawMessage `json:"round"`
Actions []combatActionWire `json:"actions"`
Summary string `json:"summary"`
SourceRefs []source.SourceRef `json:"source_refs"`
}
type combatActionWire struct {
Category dnd.CombatActionCategory `json:"category"`
Declaration string `json:"declaration"`
Targets []string `json:"targets"`
Resolution json.RawMessage `json:"resolution"`
}
func decodeNullableInt(raw json.RawMessage, field string) (*int, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("%s must be present", field)
}
trimmed := bytes.TrimSpace(raw)
if bytes.Equal(trimmed, []byte("null")) {
return nil, nil
}
var value int
if err := json.Unmarshal(trimmed, &value); err != nil {
return nil, fmt.Errorf("%s must be an integer or null: %w", field, err)
}
return &value, nil
}
func decodeNullableString(raw json.RawMessage, field string) (*string, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("%s must be present", field)
}
trimmed := bytes.TrimSpace(raw)
if bytes.Equal(trimmed, []byte("null")) {
return nil, nil
}
var value string
if err := json.Unmarshal(trimmed, &value); err != nil {
return nil, fmt.Errorf("%s must be a string or null: %w", field, err)
}
return &value, nil
}
func validate(value dnd.CombatTurnList) error { func validate(value dnd.CombatTurnList) error {
if value.CombatTurns == nil { if value.CombatTurns == nil {
return fmt.Errorf("combat_turns must be present") return fmt.Errorf("combat_turns must be present")
@@ -192,38 +105,9 @@ func validate(value dnd.CombatTurnList) error {
if !validTurnKind(turn.TurnKind) { if !validTurnKind(turn.TurnKind) {
return fmt.Errorf("%s.turn_kind must be supported", prefix) return fmt.Errorf("%s.turn_kind must be supported", prefix)
} }
if turn.Round != nil && *turn.Round <= 0 {
return fmt.Errorf("%s.round must be positive or null", prefix)
}
if len(turn.Actions) == 0 {
return fmt.Errorf("%s.actions must contain at least one action", prefix)
}
if strings.TrimSpace(turn.Summary) == "" {
return fmt.Errorf("%s.summary must not be empty", prefix)
}
if len(turn.SourceRefs) == 0 { if len(turn.SourceRefs) == 0 {
return fmt.Errorf("%s.source_refs must contain at least one reference", prefix) return fmt.Errorf("%s.source_refs must contain at least one reference", prefix)
} }
for actionIndex, action := range turn.Actions {
actionPrefix := fmt.Sprintf("%s.actions[%d]", prefix, actionIndex)
if !validActionCategory(action.Category) {
return fmt.Errorf("%s.category must be supported", actionPrefix)
}
if strings.TrimSpace(action.Declaration) == "" {
return fmt.Errorf("%s.declaration must not be empty", actionPrefix)
}
if action.Targets == nil {
return fmt.Errorf("%s.targets must be present", actionPrefix)
}
for targetIndex, target := range action.Targets {
if strings.TrimSpace(target) == "" {
return fmt.Errorf("%s.targets[%d] must not be empty", actionPrefix, targetIndex)
}
}
if action.Resolution != nil && strings.TrimSpace(*action.Resolution) == "" {
return fmt.Errorf("%s.resolution must not be empty or null", actionPrefix)
}
}
for refIndex, ref := range turn.SourceRefs { for refIndex, ref := range turn.SourceRefs {
refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex) refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex)
if strings.TrimSpace(ref.SourceID) == "" { if strings.TrimSpace(ref.SourceID) == "" {
@@ -248,12 +132,3 @@ func validTurnKind(value dnd.CombatTurnKind) bool {
return false return false
} }
} }
func validActionCategory(value dnd.CombatActionCategory) bool {
switch value {
case dnd.CombatActionCategoryAttack, dnd.CombatActionCategorySpell, dnd.CombatActionCategoryMovement, dnd.CombatActionCategoryItem, dnd.CombatActionCategoryAbilityCheck, dnd.CombatActionCategorySavingThrow, dnd.CombatActionCategoryCondition, dnd.CombatActionCategoryOther:
return true
default:
return false
}
}

View File

@@ -15,19 +15,8 @@ import (
) )
func validList() dnd.CombatTurnList { func validList() dnd.CombatTurnList {
round := 1
resolution := "The wight is hit."
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Aria", Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
TurnKind: dnd.CombatTurnKindTurn,
Round: &round,
Actions: []dnd.CombatAction{{
Category: dnd.CombatActionCategoryAttack,
Declaration: "Aria swings her sword",
Targets: []string{"wight"},
Resolution: &resolution,
}},
Summary: "Aria attacks the wight.",
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
}}} }}}
} }
@@ -35,30 +24,26 @@ func validList() dnd.CombatTurnList {
func TestCodecMatchesMaintainedDurableFixture(t *testing.T) { func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
raw, err := os.ReadFile("testdata/dnd_combat_turns.v1.json") raw, err := os.ReadFile("testdata/dnd_combat_turns.v1.json")
if err != nil { if err != nil {
t.Fatalf("read durable fixture: %v", err) t.Fatal(err)
} }
codec := New() codec := New()
value, err := codec.Decode(raw) value, err := codec.Decode(raw)
if err != nil { if err != nil {
t.Fatalf("Decode() error = %v, want nil", err) t.Fatalf("Decode() error = %v", err)
} }
if want := validList(); !reflect.DeepEqual(value, want) { if want := validList(); !reflect.DeepEqual(value, want) {
t.Fatalf("Decode() = %#v, want %#v", value, want) t.Fatalf("Decode() = %#v, want %#v", value, want)
} }
encoded, err := codec.Encode(value) encoded, err := codec.Encode(value)
if err != nil { if err != nil {
t.Fatalf("Encode() error = %v, want nil", err) t.Fatalf("Encode() error = %v", err)
} }
var compact bytes.Buffer var compact bytes.Buffer
if err := json.Compact(&compact, raw); err != nil { if err := json.Compact(&compact, raw); err != nil {
t.Fatalf("compact durable fixture: %v", err) t.Fatal(err)
} }
if !bytes.Equal(encoded, compact.Bytes()) { if !bytes.Equal(encoded, compact.Bytes()) {
t.Fatalf("Encode() = %s, want stable durable JSON %s", encoded, compact.Bytes()) t.Fatalf("Encode() = %s, want %s", encoded, compact.Bytes())
}
second, err := codec.Encode(value)
if err != nil || !bytes.Equal(second, encoded) {
t.Fatalf("second Encode() = %s, %v; want deterministic bytes", second, err)
} }
} }
@@ -69,16 +54,11 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType()) t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType())
} }
if schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) { if schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) {
t.Fatalf("schema = %#v, want durable combat-turn schema", schema) t.Fatalf("schema = %#v", schema)
} }
var document map[string]any
if err := json.Unmarshal(schema.JSONSchema, &document); err != nil || document["$id"] != SchemaID {
t.Fatalf("durable schema document = %#v, %v", document, err)
}
registry := pipeline.NewArtifactCodecRegistry() registry := pipeline.NewArtifactCodecRegistry()
if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil { if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil {
t.Fatalf("RegisterArtifactCodec() error = %v", err) t.Fatal(err)
} }
spec, ok := registry.Spec(dnd.CombatTurnListKind) spec, ok := registry.Spec(dnd.CombatTurnListKind)
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) { if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
@@ -86,176 +66,59 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
} }
} }
func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) { func TestCodecStrictlyRejectsMalformedUnknownAndInvalidJSON(t *testing.T) {
codec := New() validJSON := `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
validJSON := `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":1,"actions":[{"category":"attack","declaration":"swings","targets":["wight"],"resolution":"hits"}],"summary":"attack","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` tests := []struct{ name, raw, want string }{
tests := []struct { {"malformed", `{`, "decode dnd combat turn list"},
name string {"unknown top-level", `{"combat_turns":[],"unexpected":true}`, "unknown field"},
raw string {"unknown turn field", strings.Replace(validJSON, `"turn_kind":"turn"`, `"turn_kind":"turn","unexpected":true`, 1), "unknown field"},
want string {"unknown source field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"},
}{ {"trailing", `{"combat_turns":[]} {}`, "multiple JSON values"},
{name: "malformed", raw: `{`, want: "decode dnd combat turn list"}, {"missing list", `{}`, "combat_turns must be present"},
{name: "unknown top-level", raw: `{"combat_turns":[],"unexpected":true}`, want: "unknown field"}, {"unsupported kind", strings.Replace(validJSON, `"turn_kind":"turn"`, `"turn_kind":"unsupported"`, 1), "turn_kind must be supported"},
{name: "unknown turn field", raw: strings.Replace(validJSON, `"summary":"attack"`, `"summary":"attack","unexpected":true`, 1), want: "unknown field"},
{name: "unknown action field", raw: strings.Replace(validJSON, `"resolution":"hits"`, `"resolution":"hits","unexpected":true`, 1), want: "unknown field"},
{name: "unknown source reference field", raw: strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), want: "unknown field"},
{name: "trailing", raw: `{"combat_turns":[]} {}`, want: "multiple JSON values"},
{name: "missing top-level array", raw: `{}`, want: "combat_turns must be present"},
{name: "missing round", raw: strings.Replace(validJSON, `,"round":1`, ``, 1), want: "round must be present"},
{name: "missing resolution", raw: strings.Replace(validJSON, `,"resolution":"hits"`, ``, 1), want: "resolution must be present"},
{name: "invalid round type", raw: strings.Replace(validJSON, `"round":1`, `"round":1.5`, 1), want: "round must be an integer or null"},
{name: "unsupported turn kind", raw: strings.Replace(validJSON, `"turn_kind":"turn"`, `"turn_kind":"unsupported"`, 1), want: "turn_kind must be supported"},
{name: "empty actions", raw: strings.Replace(validJSON, `"actions":[{"category":"attack","declaration":"swings","targets":["wight"],"resolution":"hits"}]`, `"actions":[]`, 1), want: "actions must contain at least one action"},
{name: "empty target", raw: strings.Replace(validJSON, `"targets":["wight"]`, `"targets":[" "]`, 1), want: "targets[0] must not be empty"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
if _, err := codec.Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) { 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) t.Fatalf("Decode() error = %v, want %q", err, test.want)
} }
}) })
} }
} }
func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) { func TestCodecCandidatePreservesValidatorOwnedValuesAndCollectionPresence(t *testing.T) {
round := -1 candidates := []dnd.CombatTurnList{
resolution := " " {},
candidate := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ {CombatTurns: []dnd.CombatTurn{}},
Actor: " ", {CombatTurns: []dnd.CombatTurn{{Actor: " ", TurnKind: "unsupported", SourceRefs: nil}}},
TurnKind: "unsupported", {CombatTurns: []dnd.CombatTurn{{Actor: " ", TurnKind: "unsupported", SourceRefs: []source.SourceRef{}}}},
Round: &round, {CombatTurns: []dnd.CombatTurn{{Actor: " ", TurnKind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}},
Actions: []dnd.CombatAction{{ }
Category: "unsupported", for _, candidate := range candidates {
Declaration: " ",
Targets: nil,
Resolution: &resolution,
}},
Summary: " ",
SourceRefs: []source.SourceRef{{
SourceID: "",
StartUnitID: 0,
EndUnitID: -1,
}},
}}}
content, err := New().EncodeCandidate(candidate) content, err := New().EncodeCandidate(candidate)
if err != nil || !json.Valid(content) { if err != nil || !json.Valid(content) {
t.Fatalf("EncodeCandidate() = %s, %v; want JSON", content, err) t.Fatalf("EncodeCandidate() = %s, %v", content, err)
} }
decoded, err := New().DecodeCandidate(content) decoded, err := New().DecodeCandidate(content)
if err != nil || !reflect.DeepEqual(decoded, candidate) { if err != nil || !reflect.DeepEqual(decoded, candidate) {
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
} }
} }
func TestCodecCandidatePreservesNilAndPresentEmptyArrays(t *testing.T) {
codec := New()
for name, value := range map[string]dnd.CombatTurnList{
"nil combat turns": {},
"empty combat turns": {CombatTurns: []dnd.CombatTurn{}},
} {
t.Run(name, func(t *testing.T) {
content, err := codec.EncodeCandidate(value)
if err != nil {
t.Fatalf("EncodeCandidate() error = %v", err)
}
decoded, err := codec.DecodeCandidate(content)
if err != nil || !reflect.DeepEqual(decoded, value) {
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, value)
}
})
} }
withoutTargets := validList() func TestCodecRejectsRequiredShapeAndReferenceBoundaries(t *testing.T) {
withoutTargets.CombatTurns[0].Actions[0].Targets = nil
withEmptyTargets := validList()
withEmptyTargets.CombatTurns[0].Actions[0].Targets = []string{}
for name, value := range map[string]dnd.CombatTurnList{
"nil targets": withoutTargets,
"empty targets": withEmptyTargets,
} {
t.Run(name, func(t *testing.T) {
content, err := codec.EncodeCandidate(value)
if err != nil {
t.Fatalf("EncodeCandidate() error = %v", err)
}
decoded, err := codec.DecodeCandidate(content)
if err != nil || !reflect.DeepEqual(decoded, value) {
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, value)
}
})
}
withoutActions := validList()
withoutActions.CombatTurns[0].Actions = nil
withEmptyActions := validList()
withEmptyActions.CombatTurns[0].Actions = []dnd.CombatAction{}
withoutSourceRefs := validList()
withoutSourceRefs.CombatTurns[0].SourceRefs = nil
withEmptySourceRefs := validList()
withEmptySourceRefs.CombatTurns[0].SourceRefs = []source.SourceRef{}
for name, value := range map[string]dnd.CombatTurnList{
"nil actions": withoutActions,
"empty actions": withEmptyActions,
"nil source refs": withoutSourceRefs,
"empty source refs": withEmptySourceRefs,
} {
t.Run(name, func(t *testing.T) {
content, err := codec.EncodeCandidate(value)
if err != nil {
t.Fatalf("EncodeCandidate() error = %v", err)
}
decoded, err := codec.DecodeCandidate(content)
if err != nil || !reflect.DeepEqual(decoded, value) {
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, value)
}
})
}
}
func TestCodecAcceptsExplicitNullableFieldsAndEmptyTargets(t *testing.T) {
value := validList()
value.CombatTurns[0].Round = nil
value.CombatTurns[0].Actions[0].Resolution = nil
value.CombatTurns[0].Actions[0].Targets = []string{}
codec := New()
content, err := codec.Encode(value)
if err != nil {
t.Fatalf("Encode() error = %v, want nil for explicit nullable fields", err)
}
decoded, err := codec.Decode(content)
if err != nil || !reflect.DeepEqual(decoded, value) {
t.Fatalf("Decode() = %#v, %v; want %#v", decoded, err, value)
}
}
func TestCodecRejectsEveryRequiredShapeBoundary(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
value dnd.CombatTurnList value dnd.CombatTurnList
want string want string
}{ }{
{name: "nil combat turns", value: dnd.CombatTurnList{}, want: "combat_turns must be present"}, {"nil combat turns", dnd.CombatTurnList{}, "combat_turns must be present"},
{name: "empty actor", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actor = " " }), want: "actor must not be empty"}, {"empty actor", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].Actor = " " }), "actor must not be empty"},
{name: "unsupported turn kind", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].TurnKind = "unsupported" }), want: "turn_kind must be supported"}, {"unsupported turn kind", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].TurnKind = "unsupported" }), "turn_kind must be supported"},
{name: "non-positive round", value: mutate(validList(), func(value *dnd.CombatTurnList) { round := 0; value.CombatTurns[0].Round = &round }), want: "round must be positive or null"}, {"nil source refs", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs = nil }), "source_refs must contain"},
{name: "nil actions", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = nil }), want: "actions must contain at least one action"}, {"empty source ID", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"},
{name: "empty actions", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = []dnd.CombatAction{} }), want: "actions must contain at least one action"}, {"non-positive start", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"},
{name: "empty summary", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Summary = " " }), want: "summary must not be empty"}, {"non-positive end", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"},
{name: "nil source refs", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs = nil }), want: "source_refs must contain at least one reference"},
{name: "empty source refs", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs = []source.SourceRef{} }), want: "source_refs must contain at least one reference"},
{name: "unsupported action category", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Category = "unsupported" }), want: "category must be supported"},
{name: "empty declaration", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Declaration = " " }), want: "declaration must not be empty"},
{name: "nil targets", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = nil }), want: "targets must be present"},
{name: "empty target", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = []string{" "} }), want: "targets[0] must not be empty"},
{name: "empty resolution", value: mutate(validList(), func(value *dnd.CombatTurnList) {
resolution := " "
value.CombatTurns[0].Actions[0].Resolution = &resolution
}), want: "resolution must not be empty or null"},
{name: "empty source ID", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].SourceID = " " }), want: "source_id must not be empty"},
{name: "non-positive source start", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].StartUnitID = 0 }), want: "start_unit_id must be positive"},
{name: "non-positive source end", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs[0].EndUnitID = 0 }), want: "end_unit_id must be positive"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -270,14 +133,13 @@ func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
codec := New() codec := New()
first := codec.Schema() first := codec.Schema()
first.JSONSchema[0] = '[' first.JSONSchema[0] = '['
second := codec.Schema() if second := codec.Schema(); !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' {
if !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' {
t.Fatal("Schema() returned shared bytes") t.Fatal("Schema() returned shared bytes")
} }
metadata := codec.Metadata(validList()) metadata := codec.Metadata(validList())
metadata["other"] = true metadata["other"] = true
if next := codec.Metadata(validList()); len(next) != 1 || next["combat_turn_count"] != 1 { if next := codec.Metadata(validList()); len(next) != 1 || next["combat_turn_count"] != 1 {
t.Fatalf("Metadata() = %#v, want only combat_turn_count", next) t.Fatalf("Metadata() = %#v", next)
} }
} }

View File

@@ -1 +1 @@
{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":1,"actions":[{"category":"attack","declaration":"Aria swings her sword","targets":["wight"],"resolution":"The wight is hit."}],"summary":"Aria attacks the wight.","source_refs":[{"source_id":"session-alpha","start_unit_id":1,"end_unit_id":2}]}]} {"combat_turns":[{"actor":"Aria","turn_kind":"turn","source_refs":[{"source_id":"session-alpha","start_unit_id":1,"end_unit_id":2}]}]}

View File

@@ -13,9 +13,6 @@
"required": [ "required": [
"id", "id",
"name", "name",
"aliases",
"description",
"relationships",
"source_refs" "source_refs"
], ],
"properties": { "properties": {
@@ -27,35 +24,6 @@
"type": "string", "type": "string",
"minLength": 1 "minLength": 1
}, },
"aliases": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"description": {
"type": "string",
"minLength": 1
},
"relationships": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["target", "relationship"],
"properties": {
"target": {
"type": "string",
"minLength": 1
},
"relationship": {
"type": "string",
"minLength": 1
}
}
}
},
"source_refs": { "source_refs": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 1,

View File

@@ -106,29 +106,6 @@ func validate(value dnd.NPCList) error {
if strings.TrimSpace(npc.Name) == "" { if strings.TrimSpace(npc.Name) == "" {
return fmt.Errorf("%s.name must not be empty", prefix) return fmt.Errorf("%s.name must not be empty", prefix)
} }
if npc.Aliases == nil {
return fmt.Errorf("%s.aliases must be present", prefix)
}
for aliasIndex, alias := range npc.Aliases {
if strings.TrimSpace(alias) == "" {
return fmt.Errorf("%s.aliases[%d] must not be empty", prefix, aliasIndex)
}
}
if strings.TrimSpace(npc.Description) == "" {
return fmt.Errorf("%s.description must not be empty", prefix)
}
if npc.Relationships == nil {
return fmt.Errorf("%s.relationships must be present", prefix)
}
for relationshipIndex, relationship := range npc.Relationships {
relationshipPrefix := fmt.Sprintf("%s.relationships[%d]", prefix, relationshipIndex)
if strings.TrimSpace(relationship.Target) == "" {
return fmt.Errorf("%s.target must not be empty", relationshipPrefix)
}
if strings.TrimSpace(relationship.Relationship) == "" {
return fmt.Errorf("%s.relationship must not be empty", relationshipPrefix)
}
}
if len(npc.SourceRefs) == 0 { if len(npc.SourceRefs) == 0 {
return fmt.Errorf("%s.source_refs must not be empty", prefix) return fmt.Errorf("%s.source_refs must not be empty", prefix)
} }

View File

@@ -19,11 +19,6 @@ func validList() dnd.NPCList {
return dnd.NPCList{NPCs: []dnd.NPC{{ return dnd.NPCList{NPCs: []dnd.NPC{{
ID: identity.DeriveID("Mira Thorn"), ID: identity.DeriveID("Mira Thorn"),
Name: "Mira Thorn", Name: "Mira Thorn",
Aliases: []string{"The Greencloak"},
Description: "A guarded ranger who watches the northern road.",
Relationships: []dnd.NPCRelationship{{
Target: "Captain Vale", Relationship: "reports to",
}},
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
}}} }}}
} }
@@ -86,10 +81,10 @@ func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) {
want string want string
}{ }{
{name: "unknown top-level", raw: `{"npcs":[],"unexpected":true}`, want: "unknown field"}, {name: "unknown top-level", raw: `{"npcs":[],"unexpected":true}`, want: "unknown field"},
{name: "unknown nested", raw: `{"npcs":[{"id":"x","name":"Mira","aliases":[],"description":"desc","relationships":[],"source_refs":[{"source_id":"s","start_unit_id":1,"end_unit_id":1}],"unexpected":true}]}`, want: "unknown field"}, {name: "unknown nested", raw: `{"npcs":[{"id":"x","name":"Mira","source_refs":[{"source_id":"s","start_unit_id":1,"end_unit_id":1}],"unexpected":true}]}`, want: "unknown field"},
{name: "trailing", raw: `{"npcs":[]} {}`, want: "multiple JSON values"}, {name: "trailing", raw: `{"npcs":[]} {}`, want: "multiple JSON values"},
{name: "missing array", raw: `{}`, want: "npcs must be present"}, {name: "missing array", raw: `{}`, want: "npcs must be present"},
{name: "invalid reference", raw: `{"npcs":[{"id":"npc:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"Mira","aliases":[],"description":"desc","relationships":[],"source_refs":[{"source_id":"s","start_unit_id":0,"end_unit_id":1}]}]}`, want: "start_unit_id"}, {name: "invalid reference", raw: `{"npcs":[{"id":"npc:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"Mira","source_refs":[{"source_id":"s","start_unit_id":0,"end_unit_id":1}]}]}`, want: "start_unit_id"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -103,7 +98,7 @@ func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) {
func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) { func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) {
codec := New() codec := New()
candidate := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn", Aliases: []string{}, Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{}}}} candidate := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn", SourceRefs: []source.SourceRef{}}}}
content, err := codec.EncodeCandidate(candidate) content, err := codec.EncodeCandidate(candidate)
if err != nil || !json.Valid(content) { if err != nil || !json.Valid(content) {
t.Fatalf("EncodeCandidate() = %s, %v; want JSON", content, err) t.Fatalf("EncodeCandidate() = %s, %v; want JSON", content, err)
@@ -124,11 +119,8 @@ func TestCodecRejectsEveryRequiredShapeBoundary(t *testing.T) {
value dnd.NPCList value dnd.NPCList
want string want string
}{ }{
{name: "nil aliases", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, Description: base.Description, Relationships: []dnd.NPCRelationship{}, SourceRefs: base.SourceRefs}}}, want: "aliases must be present"}, {name: "blank name", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: " ", SourceRefs: base.SourceRefs}}}, want: "name must not be empty"},
{name: "empty alias", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, Aliases: []string{" "}, Description: base.Description, Relationships: []dnd.NPCRelationship{}, SourceRefs: base.SourceRefs}}}, want: "aliases[0] must not be empty"}, {name: "empty source refs", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, SourceRefs: []source.SourceRef{}}}}, want: "source_refs must not be empty"},
{name: "nil relationships", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, Aliases: []string{}, Description: base.Description, SourceRefs: base.SourceRefs}}}, want: "relationships must be present"},
{name: "empty relationship target", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, Aliases: []string{}, Description: base.Description, Relationships: []dnd.NPCRelationship{{Relationship: "knows"}}, SourceRefs: base.SourceRefs}}}, want: "target must not be empty"},
{name: "empty source refs", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, Aliases: []string{}, Description: base.Description, Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{}}}}, want: "source_refs must not be empty"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {

View File

@@ -3,9 +3,6 @@
{ {
"id": "npc:sha256:99a16589618a04f535a7d21fdcc71a0b1c05d22f752cd492065b1086d97bc3d7", "id": "npc:sha256:99a16589618a04f535a7d21fdcc71a0b1c05d22f752cd492065b1086d97bc3d7",
"name": "Mira Thorn", "name": "Mira Thorn",
"aliases": ["The Greencloak"],
"description": "A guarded ranger who watches the northern road.",
"relationships": [{"target": "Captain Vale", "relationship": "reports to"}],
"source_refs": [{"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 2}] "source_refs": [{"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 2}]
} }
] ]

View File

@@ -13,8 +13,6 @@
"required": [ "required": [
"caster", "caster",
"spell", "spell",
"effect",
"narrative_description",
"source_refs" "source_refs"
], ],
"properties": { "properties": {
@@ -26,14 +24,6 @@
"type": "string", "type": "string",
"minLength": 1 "minLength": 1
}, },
"effect": {
"type": "string",
"minLength": 1
},
"narrative_description": {
"type": "string",
"minLength": 1
},
"source_refs": { "source_refs": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 1,

View File

@@ -99,12 +99,6 @@ func validate(value dnd.SpellList) error {
if strings.TrimSpace(spell.Spell) == "" { if strings.TrimSpace(spell.Spell) == "" {
return fmt.Errorf("spell_casts[%d].spell must not be empty", index) return fmt.Errorf("spell_casts[%d].spell must not be empty", index)
} }
if strings.TrimSpace(spell.Effect) == "" {
return fmt.Errorf("spell_casts[%d].effect must not be empty", index)
}
if strings.TrimSpace(spell.NarrativeDescription) == "" {
return fmt.Errorf("spell_casts[%d].narrative_description must not be empty", index)
}
if len(spell.SourceRefs) == 0 { if len(spell.SourceRefs) == 0 {
return fmt.Errorf("spell_casts[%d].source_refs must not be empty", index) return fmt.Errorf("spell_casts[%d].source_refs must not be empty", index)
} }

View File

@@ -27,8 +27,8 @@ func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
t.Fatalf("Decode() error = %v, want nil", err) t.Fatalf("Decode() error = %v, want nil", err)
} }
want := dnd.SpellList{SpellCasts: []dnd.SpellCast{ want := dnd.SpellList{SpellCasts: []dnd.SpellCast{
{Caster: "Aria", Spell: "Cure Wounds", Effect: "Heals an injured ally.", NarrativeDescription: "Aria restores the fighter after the fight.", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}}, {Caster: "Aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}},
{Caster: "Borin", Spell: "Fire Bolt", Effect: "Scorches the wight.", NarrativeDescription: "Borin hurls fire at the wight.", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}}, {Caster: "Borin", Spell: "Fire Bolt", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
}} }}
if !reflect.DeepEqual(value, want) { if !reflect.DeepEqual(value, want) {
t.Fatalf("Decode() = %#v, want %#v", value, want) t.Fatalf("Decode() = %#v, want %#v", value, want)
@@ -82,9 +82,10 @@ func TestCodecStrictlyRejectsInvalidRepresentations(t *testing.T) {
want string want string
}{ }{
{name: "unknown", raw: `{"spell_casts":[],"unexpected":true}`, want: "unknown field"}, {name: "unknown", raw: `{"spell_casts":[],"unexpected":true}`, want: "unknown field"},
{name: "unknown record field", raw: `{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[],"unexpected":true}]}`, want: "unknown field"},
{name: "trailing", raw: `{"spell_casts":[]} {}`, want: "multiple JSON values"}, {name: "trailing", raw: `{"spell_casts":[]} {}`, want: "multiple JSON values"},
{name: "missing", raw: `{}`, want: "spell_casts must be present"}, {name: "missing", raw: `{}`, want: "spell_casts must be present"},
{name: "invalid evidence", raw: `{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"Heals","narrative_description":"Aria heals","source_refs":[{"source_id":"session","start_unit_id":0,"end_unit_id":1}]}]}`, want: "start_unit_id"}, {name: "invalid evidence", raw: `{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[{"source_id":"session","start_unit_id":0,"end_unit_id":1}]}]}`, want: "start_unit_id"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {

View File

@@ -3,8 +3,6 @@
{ {
"caster": "Aria", "caster": "Aria",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "Heals an injured ally.",
"narrative_description": "Aria restores the fighter after the fight.",
"source_refs": [ "source_refs": [
{ {
"source_id": "session-alpha", "source_id": "session-alpha",
@@ -16,8 +14,6 @@
{ {
"caster": "Borin", "caster": "Borin",
"spell": "Fire Bolt", "spell": "Fire Bolt",
"effect": "Scorches the wight.",
"narrative_description": "Borin hurls fire at the wight.",
"source_refs": [ "source_refs": [
{ {
"source_id": "session-alpha", "source_id": "session-alpha",

View File

@@ -30,8 +30,6 @@ messages:
content_file: ./sharedassets/common-dnd-references.md content_file: ./sharedassets/common-dnd-references.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-immediate-resolution.md
- role: user - role: user
content_file: ./sharedassets/common-dnd-npcs.md content_file: ./sharedassets/common-dnd-npcs.md
cache_control: cache_control:

View File

@@ -1,5 +1,6 @@
Return the combat_turns array even when no combat turn is established. Return Return the combat_turns array even when no combat turn is established. Return
one or more actions for every turn. Use one of the supported turn_kind and actor, turn_kind, and source_refs for every record. For turn_kind, use exactly
action category values. Set round to null when the transcript does not state an one of: turn, reaction, legendary_action, lair_action, or other. Cite the
explicit or unambiguous positive round number. Set resolution to null when the transcript ranges that establish both the actor and the combat event. Use the
transcript establishes the declaration but not an immediate resolution. players, party, and transcript context to map speakers to in-world actors. NPC
names may help disambiguate identity but do not replace transcript evidence.

View File

@@ -2,18 +2,17 @@ Extract Dungeons & Dragons combat-turn artifacts from the supplied transcript.
Include a record only when the transcript establishes that an in-world Include a record only when the transcript establishes that an in-world
participant takes a combat turn or performs a discrete interrupting combat participant takes a combat turn or performs a discrete interrupting combat
event. Reactions, legendary actions, lair actions, and other out-of-turn events event. Interrupting events belong at the point where they occur in transcript
belong at the point where they occur in transcript chronology. chronology.
Exclude initiative setup without a turn or combat event, tactical planning, Exclude initiative setup without a turn or combat event, tactical planning,
table talk, rules lookup, hypothetical actions, abandoned declarations, recap table talk, rules lookup, hypothetical events, abandoned intentions, recaps
of combat outside the current passage, and downstream consequences. outside the current passage, and downstream consequences.
Do not infer a round, target, roll, amount, condition, outcome, or action Do not infer combat events from D&D rules knowledge. Preserve the session as
classification from D&D rules knowledge. Preserve the session as played; played and attribute relevant nonstandard rulings to the GM or table.
attribute relevant nonstandard rulings to the GM or table.
Unmatched actors and targets remain permitted. Unmatched actors remain permitted.
Place all supporting transcript ranges for a turn in its turn-level source_refs Place all supporting transcript ranges for a turn in its turn-level source_refs
collection. collection.

View File

@@ -10,7 +10,7 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"], "required": ["actor", "turn_kind", "source_refs"],
"properties": { "properties": {
"actor": { "actor": {
"type": "string" "type": "string"
@@ -18,37 +18,6 @@
"turn_kind": { "turn_kind": {
"type": "string" "type": "string"
}, },
"round": {
"type": ["integer", "null"]
},
"actions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["category", "declaration", "targets", "resolution"],
"properties": {
"category": {
"type": "string"
},
"declaration": {
"type": "string"
},
"targets": {
"type": "array",
"items": {
"type": "string"
}
},
"resolution": {
"type": ["string", "null"]
}
}
}
},
"summary": {
"type": "string"
},
"source_refs": { "source_refs": {
"type": "array", "type": "array",
"items": { "items": {

View File

@@ -96,9 +96,6 @@ func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.C
turns[index] = dnd.CombatTurn{ turns[index] = dnd.CombatTurn{
Actor: turn.Actor, Actor: turn.Actor,
TurnKind: dnd.CombatTurnKind(turn.TurnKind), TurnKind: dnd.CombatTurnKind(turn.TurnKind),
Round: cloneIntPointer(turn.Round),
Actions: canonicalActions(turn.Actions),
Summary: turn.Summary,
SourceRefs: canonicalSourceRefs(turn.SourceRefs, sourceID), SourceRefs: canonicalSourceRefs(turn.SourceRefs, sourceID),
} }
} }
@@ -108,22 +105,6 @@ func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.C
return dnd.CombatTurnList{CombatTurns: turns} return dnd.CombatTurnList{CombatTurns: turns}
} }
func canonicalActions(actions []combatActionResponse) []dnd.CombatAction {
if actions == nil {
return nil
}
out := make([]dnd.CombatAction, len(actions))
for index, action := range actions {
out[index] = dnd.CombatAction{
Category: dnd.CombatActionCategory(action.Category),
Declaration: action.Declaration,
Targets: append([]string(nil), action.Targets...),
Resolution: cloneStringPointer(action.Resolution),
}
}
return out
}
func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []source.SourceRef { func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []source.SourceRef {
if refs == nil { if refs == nil {
return nil return nil
@@ -134,19 +115,3 @@ func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []sour
} }
return out return out
} }
func cloneIntPointer(value *int) *int {
if value == nil {
return nil
}
out := *value
return &out
}
func cloneStringPointer(value *string) *string {
if value == nil {
return nil
}
out := *value
return &out
}

View File

@@ -43,7 +43,7 @@ func referenceSlots() []contracts.ReferenceSlot {
slots := shared.ReferenceSlots(referenceSlotDescriptions) slots := shared.ReferenceSlots(referenceSlotDescriptions)
slots = append(slots, contracts.ReferenceSlot{ slots = append(slots, contracts.ReferenceSlot{
Name: NPCRegistryReferenceSlot, Name: NPCRegistryReferenceSlot,
Description: "Optional normalized NPC registry used for canonical actor and target grounding.", Description: "Optional normalized NPC registry used for canonical actor grounding.",
AcceptedMediaTypes: []string{"application/json"}, AcceptedMediaTypes: []string{"application/json"},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
MaxBytes: NPCRegistryMaxBytes, MaxBytes: NPCRegistryMaxBytes,
@@ -133,9 +133,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
{Name: "mapping_policy", Value: mappingPolicy}, {Name: "mapping_policy", Value: mappingPolicy},
} }
seeded := e.npcResolver.Seeded() seeded := e.npcResolver.Seeded()
if seeded.Bound() { fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.ProjectionDigest()})
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.Digest()})
}
return fingerprints return fingerprints
} }

View File

@@ -17,26 +17,20 @@ import (
) )
func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) { func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
round := 3
resolution := "The ogre falls back."
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{ client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{
{ {
Actor: "Borin", TurnKind: "turn", Round: &round, Actor: "Borin", TurnKind: "turn",
Actions: []combatActionResponse{{Category: "movement", Declaration: "Borin retreats", Targets: []string{"ogre"}, Resolution: nil}}, SourceRefs: []combatSourceRefResponse{{StartUnitID: 2, EndUnitID: 2}},
Summary: "Borin retreats.", SourceRefs: []combatSourceRefResponse{{StartUnitID: 2, EndUnitID: 2}},
}, },
{ {
Actor: "Aria", TurnKind: "reaction", Round: nil, Actor: "Aria", TurnKind: "reaction", SourceRefs: []combatSourceRefResponse{
Actions: []combatActionResponse{{Category: "attack", Declaration: "Aria strikes", Targets: []string{"ogre"}, Resolution: &resolution}},
Summary: "Aria reacts.", SourceRefs: []combatSourceRefResponse{
{StartUnitID: 10, EndUnitID: 10}, {StartUnitID: 10, EndUnitID: 10},
{StartUnitID: 10, EndUnitID: 10}, {StartUnitID: 10, EndUnitID: 10},
}, },
}, },
{ {
Actor: "Unknown", TurnKind: "other", Round: nil, Actor: "Unknown", TurnKind: "other",
Actions: []combatActionResponse{{Category: "other", Declaration: "something", Targets: []string{}, Resolution: nil}}, SourceRefs: []combatSourceRefResponse{{StartUnitID: 0, EndUnitID: 0}},
Summary: "Uncited event.", SourceRefs: []combatSourceRefResponse{{StartUnitID: 0, EndUnitID: 0}},
}, },
}}} }}}
@@ -51,9 +45,6 @@ func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
if !reflect.DeepEqual(result.Value.CombatTurns[0].SourceRefs, wantRefs) { if !reflect.DeepEqual(result.Value.CombatTurns[0].SourceRefs, wantRefs) {
t.Fatalf("canonical refs = %#v, want %#v", result.Value.CombatTurns[0].SourceRefs, wantRefs) t.Fatalf("canonical refs = %#v, want %#v", result.Value.CombatTurns[0].SourceRefs, wantRefs)
} }
if result.Value.CombatTurns[0].Round != nil || result.Value.CombatTurns[0].Actions[0].Resolution == nil || *result.Value.CombatTurns[0].Actions[0].Resolution != resolution {
t.Fatalf("nullable fields = %#v, want nil round and preserved resolution", result.Value.CombatTurns[0])
}
if ref := result.Value.CombatTurns[2].SourceRefs[0]; ref != (source.SourceRef{SourceID: "session-alpha"}) { if ref := result.Value.CombatTurns[2].SourceRefs[0]; ref != (source.SourceRef{SourceID: "session-alpha"}) {
t.Fatalf("invalid evidence = %#v, want source identity and invalid range preserved", ref) t.Fatalf("invalid evidence = %#v, want source identity and invalid range preserved", ref)
} }
@@ -72,13 +63,10 @@ func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
} }
func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) { func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
negativeRound := -1
emptyResolution := " "
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{ client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{
{ {
Actor: " ", TurnKind: "unsupported", Round: &negativeRound, Actor: " ", TurnKind: "unsupported",
Actions: []combatActionResponse{{Category: "unsupported", Declaration: " ", Targets: nil, Resolution: &emptyResolution}}, SourceRefs: []combatSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
Summary: " ", SourceRefs: []combatSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
}, },
}}} }}}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
@@ -86,12 +74,9 @@ func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
t.Fatalf("Extract() error = %v, want nil for candidate values", err) t.Fatalf("Extract() error = %v, want nil for candidate values", err)
} }
turn := result.Value.CombatTurns[0] turn := result.Value.CombatTurns[0]
if turn.Actor != " " || turn.TurnKind != "unsupported" || turn.Round == nil || *turn.Round != negativeRound || turn.Summary != " " { if turn.Actor != " " || turn.TurnKind != "unsupported" {
t.Fatalf("invalid turn fields = %#v, want preserved candidate values", turn) t.Fatalf("invalid turn fields = %#v, want preserved candidate values", turn)
} }
if turn.Actions == nil || turn.Actions[0].Targets != nil || turn.Actions[0].Resolution == nil || *turn.Actions[0].Resolution != emptyResolution {
t.Fatalf("invalid action fields = %#v, want preserved candidate values", turn.Actions[0])
}
if turn.SourceRefs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 99}) { if turn.SourceRefs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 99}) {
t.Fatalf("invalid source ref = %#v, want invalid range preserved", turn.SourceRefs[0]) t.Fatalf("invalid source ref = %#v, want invalid range preserved", turn.SourceRefs[0])
} }
@@ -146,17 +131,15 @@ func TestExtractUnboundRegistryUsesExactEmptyPromptAndOmitsIdentity(t *testing.T
t.Fatalf("Extract() error = %v, want nil", err) t.Fatalf("Extract() error = %v, want nil", err)
} }
input := client.requests[0].Inputs[NPCRegistryReferenceSlot] input := client.requests[0].Inputs[NPCRegistryReferenceSlot]
if string(input.Content) != `{"npcs":[]}` || input.Digest != "" || input.OriginURI != "" { if string(input.Content) != `{"npcs":[]}` || input.Digest == "" || input.OriginURI != "" {
t.Fatalf("unbound registry input = %#v, want exact empty prompt without identity", input) t.Fatalf("unbound registry input = %#v, want exact empty prompt without identity", input)
} }
metadata := extractor.ManifestMetadata() metadata := extractor.ManifestMetadata()
if _, ok := metadata["npc_registry_digest"]; ok { if _, ok := metadata["npc_registry_digest"]; ok {
t.Fatalf("unbound metadata has registry digest: %#v", metadata) t.Fatalf("unbound metadata has registry digest: %#v", metadata)
} }
for _, fingerprint := range extractor.CheckpointFingerprints() { if fingerprints := extractor.CheckpointFingerprints(); len(fingerprints) != 4 || fingerprints[3].Name != "npc_registry" || fingerprints[3].Value != input.Digest {
if fingerprint.Name == "npc_registry" { t.Fatalf("unbound fingerprints = %#v, want empty-projection identity", fingerprints)
t.Fatalf("unbound fingerprints include registry identity: %#v", extractor.CheckpointFingerprints())
}
} }
} }
@@ -175,7 +158,7 @@ func TestExtractorResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin
t.Fatalf("Extract() error = %v", err) t.Fatalf("Extract() error = %v", err)
} }
input := client.requests[0].Inputs[NPCRegistryReferenceSlot] input := client.requests[0].Inputs[NPCRegistryReferenceSlot]
if input.Digest == "" || string(input.Content) != string(content) || input.OriginURI != "" { if input.Digest == "" || string(input.Content) != `{"npcs":[{"name":"Mira Thorn"}]}` || input.OriginURI != "" {
t.Fatalf("operation NPC input = %#v, want generated canonical grounding without provenance", input) t.Fatalf("operation NPC input = %#v, want generated canonical grounding without provenance", input)
} }
if metadata := extractor.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil { if metadata := extractor.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil {
@@ -252,7 +235,7 @@ func TestExtractorManifestMetadataAndFingerprints(t *testing.T) {
t.Fatalf("metadata[%q] = %#v, want digest", key, metadata[key]) t.Fatalf("metadata[%q] = %#v, want digest", key, metadata[key])
} }
} }
wantNames := map[string]struct{}{"prompt": {}, "response_schema": {}, "mapping_policy": {}} wantNames := map[string]struct{}{"prompt": {}, "response_schema": {}, "mapping_policy": {}, "npc_registry": {}}
for _, fingerprint := range extractor.CheckpointFingerprints() { for _, fingerprint := range extractor.CheckpointFingerprints() {
if _, ok := wantNames[fingerprint.Name]; !ok { if _, ok := wantNames[fingerprint.Name]; !ok {
t.Fatalf("unexpected fingerprint = %#v", fingerprint) t.Fatalf("unexpected fingerprint = %#v", fingerprint)
@@ -340,10 +323,7 @@ func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references
func npcRegistryJSON(t *testing.T) []byte { func npcRegistryJSON(t *testing.T) []byte {
t.Helper() t.Helper()
value := dnd.NPCList{NPCs: []dnd.NPC{{ value := dnd.NPCList{NPCs: []dnd.NPC{{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: 1, EndUnitID: 1}}}}}
ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Aliases: []string{"The Greencloak"},
Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: 1, EndUnitID: 1}},
}}}
content, err := npccodec.New().Encode(value) content, err := npccodec.New().Encode(value)
if err != nil { if err != nil {
t.Fatalf("encode NPC registry: %v", err) t.Fatalf("encode NPC registry: %v", err)

View File

@@ -1,11 +1,5 @@
package combatturns package combatturns
import (
"bytes"
"encoding/json"
"fmt"
)
type extractionResponse struct { type extractionResponse struct {
CombatTurns []combatTurnResponse `json:"combat_turns"` CombatTurns []combatTurnResponse `json:"combat_turns"`
} }
@@ -13,93 +7,10 @@ type extractionResponse struct {
type combatTurnResponse struct { type combatTurnResponse struct {
Actor string `json:"actor"` Actor string `json:"actor"`
TurnKind string `json:"turn_kind"` TurnKind string `json:"turn_kind"`
Round *int `json:"round"`
Actions []combatActionResponse `json:"actions"`
Summary string `json:"summary"`
SourceRefs []combatSourceRefResponse `json:"source_refs"` SourceRefs []combatSourceRefResponse `json:"source_refs"`
} }
type combatActionResponse struct {
Category string `json:"category"`
Declaration string `json:"declaration"`
Targets []string `json:"targets"`
Resolution *string `json:"resolution"`
}
type combatSourceRefResponse struct { type combatSourceRefResponse struct {
StartUnitID int `json:"start_unit_id"` StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"` EndUnitID int `json:"end_unit_id"`
} }
func (response *combatTurnResponse) UnmarshalJSON(content []byte) error {
type responseWire struct {
Actor string `json:"actor"`
TurnKind string `json:"turn_kind"`
Round json.RawMessage `json:"round"`
Actions []combatActionResponse `json:"actions"`
Summary string `json:"summary"`
SourceRefs []combatSourceRefResponse `json:"source_refs"`
}
var wire responseWire
if err := json.Unmarshal(content, &wire); err != nil {
return err
}
round, err := decodeRequiredNullableInt(wire.Round, "round")
if err != nil {
return err
}
*response = combatTurnResponse{
Actor: wire.Actor, TurnKind: wire.TurnKind, Round: round, Actions: wire.Actions,
Summary: wire.Summary, SourceRefs: wire.SourceRefs,
}
return nil
}
func (response *combatActionResponse) UnmarshalJSON(content []byte) error {
type responseWire struct {
Category string `json:"category"`
Declaration string `json:"declaration"`
Targets []string `json:"targets"`
Resolution json.RawMessage `json:"resolution"`
}
var wire responseWire
if err := json.Unmarshal(content, &wire); err != nil {
return err
}
resolution, err := decodeRequiredNullableString(wire.Resolution, "resolution")
if err != nil {
return err
}
*response = combatActionResponse{
Category: wire.Category, Declaration: wire.Declaration, Targets: wire.Targets, Resolution: resolution,
}
return nil
}
func decodeRequiredNullableInt(raw json.RawMessage, field string) (*int, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("%s must be present", field)
}
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return nil, nil
}
var value int
if err := json.Unmarshal(raw, &value); err != nil {
return nil, fmt.Errorf("%s must be an integer or null: %w", field, err)
}
return &value, nil
}
func decodeRequiredNullableString(raw json.RawMessage, field string) (*string, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("%s must be present", field)
}
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return nil, nil
}
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return nil, fmt.Errorf("%s must be a string or null: %w", field, err)
}
return &value, nil
}

View File

@@ -2,57 +2,20 @@ package combatturns
import ( import (
"encoding/json" "encoding/json"
"strings"
"testing" "testing"
) )
func TestExtractionResponseDecodingPreservesValidatorOwnedSemantics(t *testing.T) { func TestExtractionResponseDecodingPreservesValidatorOwnedSemantics(t *testing.T) {
content := []byte(`{"combat_turns":[{"actor":"","turn_kind":"unsupported","round":-1,"actions":[{"category":"unsupported","declaration":"","targets":[],"resolution":""}],"summary":"","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`) content := []byte(`{"combat_turns":[{"actor":"","turn_kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)
var response extractionResponse var response extractionResponse
if err := json.Unmarshal(content, &response); err != nil { if err := json.Unmarshal(content, &response); err != nil {
t.Fatalf("json.Unmarshal() error = %v, want semantic candidate", err) t.Fatalf("json.Unmarshal() error = %v", err)
} }
turn := response.CombatTurns[0] turn := response.CombatTurns[0]
if turn.Round == nil || *turn.Round != -1 || turn.TurnKind != "unsupported" || turn.Actions[0].Category != "unsupported" || turn.Actions[0].Resolution == nil || *turn.Actions[0].Resolution != "" { if turn.Actor != "" || turn.TurnKind != "unsupported" {
t.Fatalf("decoded turn = %#v, want validator-owned values preserved", turn) t.Fatalf("decoded turn = %#v", turn)
} }
if turn.SourceRefs[0] != (combatSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) { if turn.SourceRefs[0] != (combatSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
t.Fatalf("decoded source reference = %#v, want nonpositive values preserved", turn.SourceRefs[0]) t.Fatalf("decoded ref = %#v", turn.SourceRefs[0])
}
}
func TestExtractionResponseDecodingDistinguishesMissingAndNullNullableFields(t *testing.T) {
validNulls := []byte(`{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":null,"actions":[{"category":"attack","declaration":"attacks","targets":[],"resolution":null}],"summary":"attacks","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)
var response extractionResponse
if err := json.Unmarshal(validNulls, &response); err != nil {
t.Fatalf("json.Unmarshal(nulls) error = %v", err)
}
if response.CombatTurns[0].Round != nil || response.CombatTurns[0].Actions[0].Resolution != nil {
t.Fatalf("decoded nullables = %#v, want explicit null", response.CombatTurns[0])
}
for _, test := range []struct {
name string
content string
field string
}{
{
name: "missing round",
content: `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","actions":[],"summary":"attacks","source_refs":[]}]}`,
field: "round",
},
{
name: "missing resolution",
content: `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":null,"actions":[{"category":"attack","declaration":"attacks","targets":[]}],"summary":"attacks","source_refs":[]}]}`,
field: "resolution",
},
} {
t.Run(test.name, func(t *testing.T) {
var candidate extractionResponse
err := json.Unmarshal([]byte(test.content), &candidate)
if err == nil || !strings.Contains(err.Error(), test.field) {
t.Fatalf("json.Unmarshal() error = %v, want missing %s failure", err, test.field)
}
})
} }
} }

View File

@@ -46,13 +46,6 @@ func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *tes
turn := semanticCandidate["combat_turns"].([]any)[0].(map[string]any) turn := semanticCandidate["combat_turns"].([]any)[0].(map[string]any)
turn["actor"] = "" turn["actor"] = ""
turn["turn_kind"] = "unsupported" turn["turn_kind"] = "unsupported"
turn["round"] = -1
turn["summary"] = ""
action := turn["actions"].([]any)[0].(map[string]any)
action["category"] = "unsupported"
action["declaration"] = ""
action["targets"] = []any{""}
action["resolution"] = ""
ref := turn["source_refs"].([]any)[0].(map[string]any) ref := turn["source_refs"].([]any)[0].(map[string]any)
ref["start_unit_id"] = 0 ref["start_unit_id"] = 0
ref["end_unit_id"] = -1 ref["end_unit_id"] = -1
@@ -63,7 +56,6 @@ func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *tes
if err := validateJSONSchema(content, schema.JSONSchema); err != nil { if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
t.Fatalf("private schema rejected validator-owned semantics: %v", err) t.Fatalf("private schema rejected validator-owned semantics: %v", err)
} }
turn["actions"] = []any{}
turn["source_refs"] = []any{} turn["source_refs"] = []any{}
content, err = json.Marshal(semanticCandidate) content, err = json.Marshal(semanticCandidate)
if err != nil { if err != nil {
@@ -83,12 +75,10 @@ func TestResponseSchemaRetainsStructuralBoundary(t *testing.T) {
name string name string
mutate func(map[string]any) mutate func(map[string]any)
}{ }{
{name: "missing nullable round", mutate: func(turn map[string]any) { delete(turn, "round") }}, {name: "missing actor", mutate: func(turn map[string]any) { delete(turn, "actor") }},
{name: "wrong round type", mutate: func(turn map[string]any) { turn["round"] = "one" }}, {name: "wrong actor type", mutate: func(turn map[string]any) { turn["actor"] = 1 }},
{name: "unknown field", mutate: func(turn map[string]any) { turn["unexpected"] = true }}, {name: "unknown field", mutate: func(turn map[string]any) { turn["unexpected"] = true }},
{name: "missing nullable resolution", mutate: func(turn map[string]any) { {name: "missing source refs", mutate: func(turn map[string]any) { delete(turn, "source_refs") }},
delete(turn["actions"].([]any)[0].(map[string]any), "resolution")
}},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
candidate := validCombatResponse() candidate := validCombatResponse()
@@ -134,11 +124,8 @@ func validCombatResponse() map[string]any {
return map[string]any{ return map[string]any{
"combat_turns": []any{ "combat_turns": []any{
map[string]any{ map[string]any{
"actor": "Aria", "turn_kind": "reaction", "round": nil, "actor": "Aria",
"actions": []any{map[string]any{ "turn_kind": "reaction",
"category": "attack", "declaration": "Aria strikes", "targets": []any{}, "resolution": nil,
}},
"summary": "Aria reacts.",
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}}, "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
}, },
}, },

View File

@@ -24,7 +24,6 @@ var promptAssetManifest = shared.PromptAssetManifest{
"common-dnd-identity.md", "common-dnd-identity.md",
"common-dnd-transcript.md", "common-dnd-transcript.md",
"common-dnd-references.md", "common-dnd-references.md",
"common-dnd-immediate-resolution.md",
"common-dnd-npcs.md", "common-dnd-npcs.md",
}, },
} }

View File

@@ -1,11 +1,8 @@
For every NPC record, cite transcript units that support the canonical name, For every NPC record, return only the observed display name and transcript
every alias, the description, and every relationship. units that support that identity.
Descriptions must be short session records, not biographies, statistics, Return no other details or lore inferred from general D&D knowledge. Do not
alignment, motivations, or lore inferred from general D&D knowledge. Do not invent a label for an anonymous creature, crowd, or generic role.
summarize every action or follow a participant through unrelated scenes.
Relationships must be stated or directly demonstrated by cited transcript
units, not inferred from game lore.
Return aliases and relationships as arrays, including empty arrays when there Preserve observed display spelling. Return at least one narrow source range for
are none. Preserve observed display spelling. every record.

View File

@@ -1,15 +1,12 @@
Extract a concise Dungeons & Dragons non-player-character registry from the Extract the individually identifiable Dungeons & Dragons non-player characters
provided transcript. established by the provided transcript and cite where each identity appears.
Include an in-world non-PC participant when the transcript establishes that it Include an in-world non-PC participant only when the transcript gives it a
appears, acts, speaks, or is materially discussed and gives it a proper name, proper name or a stable, individually distinguishing title or alias.
a stable alias or title, or an individually useful distinguishing description.
Exclude human players, transcript speakers, and the GM as out-of-world people, Exclude human players, transcript speakers, and the GM as out-of-world people,
player characters identified by the player or party references, incidental or player characters identified by the player or party references, incidental or
hypothetical name drops, corrected transcription mistakes, indistinguishable hypothetical name drops, corrected transcription mistakes, anonymous or
crowds or groups, and temporary summoned creatures or spell effects without a generic roles, indistinguishable crowds or groups, invented descriptive labels,
persistent individual identity. and temporary summoned creatures or spell effects without a persistent
individual identity.
Keep each description concise and limited to facts established by the
transcript. Include only explicitly supported aliases and relationships.

View File

@@ -12,60 +12,24 @@
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": [
"name", "name",
"aliases",
"description",
"relationships",
"source_refs" "source_refs"
], ],
"properties": { "properties": {
"name": { "name": {
"type": "string", "type": "string"
"minLength": 1
},
"aliases": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"description": {
"type": "string",
"minLength": 1
},
"relationships": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["target", "relationship"],
"properties": {
"target": {
"type": "string",
"minLength": 1
},
"relationship": {
"type": "string",
"minLength": 1
}
}
}
}, },
"source_refs": { "source_refs": {
"type": "array", "type": "array",
"minItems": 1,
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["start_unit_id", "end_unit_id"], "required": ["start_unit_id", "end_unit_id"],
"properties": { "properties": {
"start_unit_id": { "start_unit_id": {
"type": "integer", "type": "integer"
"minimum": 1
}, },
"end_unit_id": { "end_unit_id": {
"type": "integer", "type": "integer"
"minimum": 1
} }
} }
} }

View File

@@ -6,7 +6,6 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
func canonicalizeResponse(response *extractionResponse, doc *source.SourceDocument) { func canonicalizeResponse(response *extractionResponse, doc *source.SourceDocument) {
@@ -33,10 +32,6 @@ func canonicalizeNPC(npc *npcResponse) {
if npc == nil { if npc == nil {
return return
} }
for index := range npc.SourceRefs {
npc.SourceRefs[index].StartUnitID = canonicalUnitRef(npc.SourceRefs[index].StartUnitID)
npc.SourceRefs[index].EndUnitID = canonicalUnitRef(npc.SourceRefs[index].EndUnitID)
}
sort.SliceStable(npc.SourceRefs, func(i, j int) bool { sort.SliceStable(npc.SourceRefs, func(i, j int) bool {
left := npc.SourceRefs[i] left := npc.SourceRefs[i]
right := npc.SourceRefs[j] right := npc.SourceRefs[j]
@@ -48,14 +43,6 @@ func canonicalizeNPC(npc *npcResponse) {
npc.SourceRefs = dedupeSourceRefs(npc.SourceRefs) npc.SourceRefs = dedupeSourceRefs(npc.SourceRefs)
} }
func canonicalUnitRef(ref shared.UnitRef) shared.UnitRef {
value := ref.Int()
if value <= 0 {
return ref
}
return shared.UnitRefFromInt(value)
}
func dedupeSourceRefs(refs []npcSourceRefResponse) []npcSourceRefResponse { func dedupeSourceRefs(refs []npcSourceRefResponse) []npcSourceRefResponse {
if len(refs) < 2 { if len(refs) < 2 {
return refs return refs
@@ -73,16 +60,15 @@ func dedupeSourceRefs(refs []npcSourceRefResponse) []npcSourceRefResponse {
} }
func sameSourceRef(left npcSourceRefResponse, right npcSourceRefResponse) bool { func sameSourceRef(left npcSourceRefResponse, right npcSourceRefResponse) bool {
return left.StartUnitID.Int() == right.StartUnitID.Int() && return left.StartUnitID == right.StartUnitID && left.EndUnitID == right.EndUnitID
left.EndUnitID.Int() == right.EndUnitID.Int()
} }
func earliestSourceIndex(doc *source.SourceDocument, npc npcResponse) (int, bool) { func earliestSourceIndex(doc *source.SourceDocument, npc npcResponse) (int, bool) {
earliest := 0 earliest := 0
found := false found := false
for _, ref := range npc.SourceRefs { for _, ref := range npc.SourceRefs {
start := ref.StartUnitID.Int() start := ref.StartUnitID
end := ref.EndUnitID.Int() end := ref.EndUnitID
if start > 0 && end > 0 { if start > 0 && end > 0 {
startIndex, startOK := source.UnitIndex(doc, start) startIndex, startOK := source.UnitIndex(doc, start)
endIndex, endOK := source.UnitIndex(doc, end) endIndex, endOK := source.UnitIndex(doc, end)
@@ -98,8 +84,7 @@ func earliestSourceIndex(doc *source.SourceDocument, npc npcResponse) (int, bool
return earliest, found return earliest, found
} }
func unitSortValue(ref shared.UnitRef) int { func unitSortValue(value int) int {
value := ref.Int()
if value <= 0 { if value <= 0 {
return int(^uint(0) >> 1) return int(^uint(0) >> 1)
} }
@@ -115,33 +100,12 @@ func canonicalNPCList(response extractionResponse, sourceID string) dnd.NPCList
npcs[index] = dnd.NPC{ npcs[index] = dnd.NPC{
ID: identity.DeriveID(npc.Name), ID: identity.DeriveID(npc.Name),
Name: npc.Name, Name: npc.Name,
Aliases: cloneStrings(npc.Aliases),
Description: npc.Description,
Relationships: cloneRelationships(npc.Relationships),
SourceRefs: canonicalSourceRefs(npc.SourceRefs, sourceID), SourceRefs: canonicalSourceRefs(npc.SourceRefs, sourceID),
} }
} }
return dnd.NPCList{NPCs: npcs} return dnd.NPCList{NPCs: npcs}
} }
func cloneStrings(values []string) []string {
if values == nil {
return nil
}
return append([]string{}, values...)
}
func cloneRelationships(values []npcRelationshipResponse) []dnd.NPCRelationship {
if values == nil {
return nil
}
out := make([]dnd.NPCRelationship, len(values))
for index, value := range values {
out[index] = dnd.NPCRelationship{Target: value.Target, Relationship: value.Relationship}
}
return out
}
func canonicalSourceRefs(values []npcSourceRefResponse, sourceID string) []source.SourceRef { func canonicalSourceRefs(values []npcSourceRefResponse, sourceID string) []source.SourceRef {
if values == nil { if values == nil {
return nil return nil
@@ -150,8 +114,8 @@ func canonicalSourceRefs(values []npcSourceRefResponse, sourceID string) []sourc
for index, value := range values { for index, value := range values {
out[index] = source.SourceRef{ out[index] = source.SourceRef{
SourceID: sourceID, SourceID: sourceID,
StartUnitID: value.StartUnitID.Int(), StartUnitID: value.StartUnitID,
EndUnitID: value.EndUnitID.Int(), EndUnitID: value.EndUnitID,
} }
} }
return out return out

View File

@@ -11,23 +11,19 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) { func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{ client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
{ {
Name: "Captain Vale", Aliases: []string{"The Captain"}, Description: "A road captain.", Name: "Captain Vale", SourceRefs: responseSourceRefs(3, 3),
Relationships: []npcRelationshipResponse{{Target: "Mira Thorn", Relationship: "reports to"}},
SourceRefs: responseSourceRefs(3, 3),
}, },
{ {
Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A guarded ranger.", Name: "Mira Thorn",
Relationships: []npcRelationshipResponse{{Target: "Captain Vale", Relationship: "commands"}},
SourceRefs: []npcSourceRefResponse{ SourceRefs: []npcSourceRefResponse{
{StartUnitID: sharedUnitRef(2), EndUnitID: sharedUnitRef(2)}, {StartUnitID: 2, EndUnitID: 2},
{StartUnitID: sharedUnitRef(1), EndUnitID: sharedUnitRef(2)}, {StartUnitID: 1, EndUnitID: 2},
{StartUnitID: sharedUnitRef(1), EndUnitID: sharedUnitRef(2)}, {StartUnitID: 1, EndUnitID: 2},
}, },
}, },
}}} }}}
@@ -37,8 +33,8 @@ func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
t.Fatalf("Extract() error = %v, want nil", err) t.Fatalf("Extract() error = %v, want nil", err)
} }
want := dnd.NPCList{NPCs: []dnd.NPC{ want := dnd.NPCList{NPCs: []dnd.NPC{
{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A guarded ranger.", Relationships: []dnd.NPCRelationship{{Target: "Captain Vale", Relationship: "commands"}}, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}}, {ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}},
{ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", Aliases: []string{"The Captain"}, Description: "A road captain.", Relationships: []dnd.NPCRelationship{{Target: "Mira Thorn", Relationship: "reports to"}}, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}}, {ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
}} }}
if !reflect.DeepEqual(result.Value, want) { if !reflect.DeepEqual(result.Value, want) {
t.Fatalf("Value = %#v, want %#v", result.Value, want) t.Fatalf("Value = %#v, want %#v", result.Value, want)
@@ -59,14 +55,14 @@ func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
func TestExtractOrdersNPCsBySourcePositionRatherThanUnitID(t *testing.T) { func TestExtractOrdersNPCsBySourcePositionRatherThanUnitID(t *testing.T) {
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{ client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
{ {
Name: "Later NPC", Aliases: []string{}, Description: "Appears later.", Relationships: []npcRelationshipResponse{}, Name: "Later NPC",
SourceRefs: responseSourceRefs(10, 10), SourceRefs: responseSourceRefs(10, 10),
}, },
{ {
Name: "Earlier NPC", Aliases: []string{}, Description: "Appears first.", Relationships: []npcRelationshipResponse{}, Name: "Earlier NPC",
SourceRefs: []npcSourceRefResponse{ SourceRefs: []npcSourceRefResponse{
{StartUnitID: sharedUnitRef(50), EndUnitID: sharedUnitRef(50)}, {StartUnitID: 50, EndUnitID: 50},
{StartUnitID: sharedUnitRef(100), EndUnitID: sharedUnitRef(100)}, {StartUnitID: 100, EndUnitID: 100},
}, },
}, },
}}} }}}
@@ -110,14 +106,14 @@ func TestExtractPassesCampaignReferencesAsPromptInputs(t *testing.T) {
func TestExtractPreservesMalformedCandidatesForValidators(t *testing.T) { func TestExtractPreservesMalformedCandidatesForValidators(t *testing.T) {
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{{ client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{{
Name: "", Aliases: nil, Description: "", Relationships: nil, Name: "",
SourceRefs: []npcSourceRefResponse{{StartUnitID: sharedUnitRef(99), EndUnitID: shared.UnitRefFromString("missing")}, {StartUnitID: sharedUnitRef(99), EndUnitID: shared.UnitRefFromInt(0)}}, SourceRefs: []npcSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
}}}} }}}}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
if err != nil { if err != nil {
t.Fatalf("Extract() error = %v, want nil", err) t.Fatalf("Extract() error = %v, want nil", err)
} }
if len(result.Value.NPCs) != 1 || result.Value.NPCs[0].ID != "" || result.Value.NPCs[0].Name != "" || result.Value.NPCs[0].Aliases != nil || result.Value.NPCs[0].Relationships != nil { if len(result.Value.NPCs) != 1 || result.Value.NPCs[0].ID != "" || result.Value.NPCs[0].Name != "" {
t.Fatalf("malformed candidate = %#v, want invalid values preserved", result.Value) t.Fatalf("malformed candidate = %#v, want invalid values preserved", result.Value)
} }
if refs := result.Value.NPCs[0].SourceRefs; len(refs) != 1 || refs[0].SourceID != "session-alpha" || refs[0].StartUnitID != 99 || refs[0].EndUnitID != 0 { if refs := result.Value.NPCs[0].SourceRefs; len(refs) != 1 || refs[0].SourceID != "session-alpha" || refs[0].StartUnitID != 99 || refs[0].EndUnitID != 0 {
@@ -125,6 +121,21 @@ func TestExtractPreservesMalformedCandidatesForValidators(t *testing.T) {
} }
} }
func TestExtractMapsRawSemanticCandidatesWithoutRepair(t *testing.T) {
client := &fakeNPCsLLMClient{content: []byte(`{"npcs":[{"name":"","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
if err != nil {
t.Fatalf("Extract() error = %v, want nil", err)
}
npc := result.Value.NPCs[0]
if npc.ID != "" || npc.Name != "" {
t.Fatalf("NPC = %#v, want blank semantic values preserved", npc)
}
if refs := npc.SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 0, EndUnitID: -1}) {
t.Fatalf("source refs = %#v, want raw nonpositive candidates preserved", refs)
}
}
func TestExtractHandlesCancellationAndProviderErrors(t *testing.T) { func TestExtractHandlesCancellationAndProviderErrors(t *testing.T) {
request := extractionRequest() request := extractionRequest()
extractor := newExtractor(t, &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}}) extractor := newExtractor(t, &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}})
@@ -142,5 +153,3 @@ func TestExtractHandlesCancellationAndProviderErrors(t *testing.T) {
t.Fatalf("provider Extract() error = %v, want contextual provider error", err) t.Fatalf("provider Extract() error = %v, want contextual provider error", err)
} }
} }
func sharedUnitRef(value int) shared.UnitRef { return shared.UnitRefFromInt(value) }

View File

@@ -1,25 +1,15 @@
package npcs package npcs
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
type extractionResponse struct { type extractionResponse struct {
NPCs []npcResponse `json:"npcs"` NPCs []npcResponse `json:"npcs"`
} }
type npcResponse struct { type npcResponse struct {
Name string `json:"name"` Name string `json:"name"`
Aliases []string `json:"aliases"`
Description string `json:"description"`
Relationships []npcRelationshipResponse `json:"relationships"`
SourceRefs []npcSourceRefResponse `json:"source_refs"` SourceRefs []npcSourceRefResponse `json:"source_refs"`
} }
type npcRelationshipResponse struct {
Target string `json:"target"`
Relationship string `json:"relationship"`
}
type npcSourceRefResponse struct { type npcSourceRefResponse struct {
StartUnitID shared.UnitRef `json:"start_unit_id"` StartUnitID int `json:"start_unit_id"`
EndUnitID shared.UnitRef `json:"end_unit_id"` EndUnitID int `json:"end_unit_id"`
} }

View File

@@ -18,7 +18,7 @@ func TestLoadResponseSchemaUsesPrivateNPCSchema(t *testing.T) {
t.Fatalf("schema = %#v, want private NPC schema identity", schema) t.Fatalf("schema = %#v, want private NPC schema identity", schema)
} }
valid := map[string]any{"npcs": []any{map[string]any{ valid := map[string]any{"npcs": []any{map[string]any{
"name": "Mira Thorn", "aliases": []any{}, "description": "A ranger.", "relationships": []any{}, "name": "Mira Thorn",
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}}, "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
}}} }}}
validJSON, err := json.Marshal(valid) validJSON, err := json.Marshal(valid)
@@ -28,8 +28,68 @@ func TestLoadResponseSchemaUsesPrivateNPCSchema(t *testing.T) {
if err := validateJSONSchema(validJSON, schema.JSONSchema); err != nil { if err := validateJSONSchema(validJSON, schema.JSONSchema); err != nil {
t.Fatalf("valid private NPC response rejected: %v", err) t.Fatalf("valid private NPC response rejected: %v", err)
} }
for _, test := range []struct {
name string
response map[string]any
valid bool
}{
{
name: "semantic blanks and empty collections",
response: map[string]any{"npcs": []any{map[string]any{
"name": "", "source_refs": []any{},
}}},
valid: true,
},
{
name: "nonpositive unit candidates",
response: map[string]any{"npcs": []any{map[string]any{
"name": "Mira Thorn",
"source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": -1}},
}}},
valid: true,
},
{
name: "missing required field",
response: map[string]any{"npcs": []any{map[string]any{
"source_refs": []any{},
}}},
},
{
name: "unknown field",
response: map[string]any{"npcs": []any{map[string]any{
"name": "Mira Thorn", "source_refs": []any{}, "id": "assigned later",
}}},
},
{
name: "wrong field type",
response: map[string]any{"npcs": []any{map[string]any{
"name": 7, "source_refs": []any{},
}}},
},
{
name: "noninteger source identifier",
response: map[string]any{"npcs": []any{map[string]any{
"name": "Mira Thorn",
"source_refs": []any{map[string]any{"start_unit_id": 1.5, "end_unit_id": 2}},
}}},
},
} {
t.Run(test.name, func(t *testing.T) {
content, err := json.Marshal(test.response)
if err != nil {
t.Fatal(err)
}
err = validateJSONSchema(content, schema.JSONSchema)
if (err == nil) != test.valid {
t.Fatalf("validateJSONSchema() error = %v, want valid=%t", err, test.valid)
}
})
}
if err := validateJSONSchema([]byte(`{"npcs":`), schema.JSONSchema); err == nil {
t.Fatal("validateJSONSchema() error = nil, want malformed JSON rejected")
}
withID := map[string]any{"npcs": []any{map[string]any{ withID := map[string]any{"npcs": []any{map[string]any{
"name": "Mira Thorn", "id": "assigned-later", "aliases": []any{}, "description": "A ranger.", "relationships": []any{}, "name": "Mira Thorn", "id": "assigned-later",
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}}, "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
}}} }}}
withIDJSON, err := json.Marshal(withID) withIDJSON, err := json.Marshal(withID)

View File

@@ -8,7 +8,6 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
func extractionRequest() contracts.TypedExtractionRequest { func extractionRequest() contracts.TypedExtractionRequest {
@@ -47,7 +46,7 @@ func sourceDocument() *source.SourceDocument {
} }
func responseSourceRefs(startUnitID, endUnitID int) []npcSourceRefResponse { func responseSourceRefs(startUnitID, endUnitID int) []npcSourceRefResponse {
return []npcSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(startUnitID), EndUnitID: shared.UnitRefFromInt(endUnitID)}} return []npcSourceRefResponse{{StartUnitID: startUnitID, EndUnitID: endUnitID}}
} }
func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor { func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor {
@@ -99,9 +98,13 @@ func (client *fakeNPCsLLMClient) CompleteStructured(_ context.Context, req contr
if !ok { if !ok {
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target") return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target")
} }
*target = client.response
content := append([]byte(nil), client.content...) content := append([]byte(nil), client.content...)
if len(content) == 0 { if len(content) != 0 {
if err := json.Unmarshal(content, target); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
} else {
*target = client.response
var err error var err error
content, err = json.Marshal(client.response) content, err = json.Marshal(client.response)
if err != nil { if err != nil {

View File

@@ -33,8 +33,6 @@ messages:
content_file: ./sharedassets/common-dnd-references.md content_file: ./sharedassets/common-dnd-references.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user
content_file: ./sharedassets/common-dnd-immediate-resolution.md
- role: user - role: user
content_file: ./sharedassets/common-dnd-npcs.md content_file: ./sharedassets/common-dnd-npcs.md
cache_control: cache_control:

View File

@@ -1,14 +1,8 @@
For each spell cast, source references must collectively support the caster, For each spell cast, source references must collectively support the in-world
spell, effect, and narrative_description. If a detail is not supported by the caster, spell name, and the fact that the cast or declared attempt occurred.
cited transcript units, omit that detail or describe only the supported attempt
or declaration.
For spells, do not follow summoned creatures, persistent effects, or other Return only D&D spell-cast artifacts. For each record, identify the in-world
downstream consequences through the rest of the scene. caster, canonical spell name, and source references.
Return only D&D spell-cast artifacts. For each spell cast, identify the
in-world caster, spell name, effect, narrative description, and source
references.
Use the player and party references together with transcript context to map Use the player and party references together with transcript context to map
first-person player speech to the associated player character and use the first-person player speech to the associated player character and use the
@@ -20,10 +14,3 @@ transcript; do not invent a name.
Use the canonical spell-name catalog to select spell names. Do not return a Use the canonical spell-name catalog to select spell names. Do not return a
spell name absent from that catalog, even when it is suggested by general D&D spell name absent from that catalog, even when it is suggested by general D&D
knowledge or reference material. knowledge or reference material.
Effects and narrative descriptions are session records, not rules summaries.
Report only mechanics, explanations, and outcomes established by the cited
transcript units. Preserve the table's observed resolution without silently
correcting it from general D&D knowledge. If the transcript gives a possibly
nonstandard rationale, use wording such as "the GM rules" or "the table
resolves" rather than asserting that rationale as a universal rule.

View File

@@ -1,15 +1,9 @@
Extract Dungeons & Dragons spell-cast artifacts from the provided transcript. Extract Dungeons & Dragons spell-cast artifacts from the provided transcript.
Do not infer a spell cast from general D&D knowledge or from table chatter that Include an actual casting event or an unambiguous declared casting attempt.
does not identify a spell being cast. Exclude spell mentions, hypothetical plans, rules discussion, and catalog
matches that do not establish a casting event in the transcript.
Describe the session as it was played and adjudicated. The transcript is
authoritative for what happened in this session, even when a table ruling may
differ from published D&D rules. Do not correct the transcript or fill in
unstated mechanics from general D&D knowledge. When a ruling or mechanical
explanation matters, attribute it to the GM or table instead of presenting it
as a universal game rule.
Use the provided canonical spell-name catalog when naming each extracted spell. Use the provided canonical spell-name catalog when naming each extracted spell.
Return the canonical catalog spelling exactly. The catalog is a recognition Return the canonical catalog spelling exactly. The catalog is a recognition
aid; it does not establish that a spell was cast or how the spell works. aid and never evidence that a spell was cast.

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.dnd.spells.llm", "$id": "notarius.dnd.spells",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["spell_casts"], "required": ["spell_casts"],
@@ -13,47 +13,30 @@
"required": [ "required": [
"caster", "caster",
"spell", "spell",
"effect",
"narrative_description",
"source_refs" "source_refs"
], ],
"properties": { "properties": {
"caster": { "caster": {
"type": "string", "type": "string",
"minLength": 1,
"description": "Canonical in-world character or creature that casts the spell, never the human player, transcript speaker, or GM when the in-world caster can be identified." "description": "Canonical in-world character or creature that casts the spell, never the human player, transcript speaker, or GM when the in-world caster can be identified."
}, },
"spell": { "spell": {
"type": "string", "type": "string",
"minLength": 1,
"description": "Canonical spell name from the provided spell-name catalog." "description": "Canonical spell name from the provided spell-name catalog."
}, },
"effect": {
"type": "string",
"minLength": 1,
"description": "Concise immediate effect or resolution established by the cited transcript units; do not infer mechanics from general D&D rules knowledge or follow persistent downstream consequences."
},
"narrative_description": {
"type": "string",
"minLength": 1,
"description": "Short session-grounded description of the casting declaration and immediate resolution, containing only details established by the cited transcript units."
},
"source_refs": { "source_refs": {
"type": "array", "type": "array",
"minItems": 1, "description": "Transcript ranges offered as evidence for the caster, spell name, and casting event in this spell-cast object.",
"description": "One or more narrow transcript ranges that collectively support every factual claim about the casting declaration and immediate resolution in this spell-cast object.",
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["start_unit_id", "end_unit_id"], "required": ["start_unit_id", "end_unit_id"],
"properties": { "properties": {
"start_unit_id": { "start_unit_id": {
"type": "integer", "type": "integer"
"minimum": 1
}, },
"end_unit_id": { "end_unit_id": {
"type": "integer", "type": "integer"
"minimum": 1
} }
} }
} }

View File

@@ -5,7 +5,6 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
func canonicalizeResponse(response *extractionResponse) { func canonicalizeResponse(response *extractionResponse) {
@@ -29,14 +28,10 @@ func canonicalizeResponse(response *extractionResponse) {
} }
func canonicalizeSpellCast(spell *spellCastResponse) { func canonicalizeSpellCast(spell *spellCastResponse) {
for index := range spell.SourceRefs {
spell.SourceRefs[index].StartUnitID = canonicalUnitRef(spell.SourceRefs[index].StartUnitID)
spell.SourceRefs[index].EndUnitID = canonicalUnitRef(spell.SourceRefs[index].EndUnitID)
}
sort.SliceStable(spell.SourceRefs, func(i, j int) bool { sort.SliceStable(spell.SourceRefs, func(i, j int) bool {
left := spell.SourceRefs[i] left := spell.SourceRefs[i]
right := spell.SourceRefs[j] right := spell.SourceRefs[j]
if left.StartUnitID.Int() != right.StartUnitID.Int() { if left.StartUnitID != right.StartUnitID {
return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID) return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID)
} }
return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID) return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID)
@@ -44,14 +39,6 @@ func canonicalizeSpellCast(spell *spellCastResponse) {
spell.SourceRefs = dedupeSourceRefs(spell.SourceRefs) spell.SourceRefs = dedupeSourceRefs(spell.SourceRefs)
} }
func canonicalUnitRef(ref shared.UnitRef) shared.UnitRef {
value := ref.Int()
if value <= 0 {
return ref
}
return shared.UnitRefFromInt(value)
}
func dedupeSourceRefs(refs []spellSourceRefResponse) []spellSourceRefResponse { func dedupeSourceRefs(refs []spellSourceRefResponse) []spellSourceRefResponse {
if len(refs) < 2 { if len(refs) < 2 {
return refs return refs
@@ -69,13 +56,12 @@ func dedupeSourceRefs(refs []spellSourceRefResponse) []spellSourceRefResponse {
} }
func sameSourceRef(left spellSourceRefResponse, right spellSourceRefResponse) bool { func sameSourceRef(left spellSourceRefResponse, right spellSourceRefResponse) bool {
return left.StartUnitID.Int() == right.StartUnitID.Int() && return left.StartUnitID == right.StartUnitID && left.EndUnitID == right.EndUnitID
left.EndUnitID.Int() == right.EndUnitID.Int()
} }
func earliestSourceUnit(spell spellCastResponse) (int, bool) { func earliestSourceUnit(spell spellCastResponse) (int, bool) {
for _, ref := range spell.SourceRefs { for _, ref := range spell.SourceRefs {
start := ref.StartUnitID.Int() start := ref.StartUnitID
if start > 0 { if start > 0 {
return start, true return start, true
} }
@@ -83,8 +69,7 @@ func earliestSourceUnit(spell spellCastResponse) (int, bool) {
return 0, false return 0, false
} }
func unitSortValue(ref shared.UnitRef) int { func unitSortValue(value int) int {
value := ref.Int()
if value <= 0 { if value <= 0 {
return int(^uint(0) >> 1) return int(^uint(0) >> 1)
} }
@@ -98,15 +83,13 @@ func canonicalSpellList(response extractionResponse, sourceID string) dnd.SpellL
for refIndex, ref := range spell.SourceRefs { for refIndex, ref := range spell.SourceRefs {
refs[refIndex] = source.SourceRef{ refs[refIndex] = source.SourceRef{
SourceID: sourceID, SourceID: sourceID,
StartUnitID: ref.StartUnitID.Int(), StartUnitID: ref.StartUnitID,
EndUnitID: ref.EndUnitID.Int(), EndUnitID: ref.EndUnitID,
} }
} }
spellCasts[index] = dnd.SpellCast{ spellCasts[index] = dnd.SpellCast{
Caster: spell.Caster, Caster: spell.Caster,
Spell: spell.Spell, Spell: spell.Spell,
Effect: spell.Effect,
NarrativeDescription: spell.NarrativeDescription,
SourceRefs: refs, SourceRefs: refs,
} }
} }

View File

@@ -151,9 +151,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
{Name: "response_schema", Value: e.responseSchemaSHA}, {Name: "response_schema", Value: e.responseSchemaSHA},
} }
seeded := e.npcResolver.Seeded() seeded := e.npcResolver.Seeded()
if seeded.Bound() { fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.ProjectionDigest()})
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.Digest()})
}
return fingerprints return fingerprints
} }

View File

@@ -21,8 +21,6 @@ func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) {
{ {
Caster: " Aria ", Caster: " Aria ",
Spell: " Cure Wounds ", Spell: " Cure Wounds ",
Effect: " Heals an injured ally. ",
NarrativeDescription: " Aria restores the fighter after the fight. ",
SourceRefs: responseSourceRefs(1, 2), SourceRefs: responseSourceRefs(1, 2),
}, },
}}} }}}
@@ -36,8 +34,6 @@ func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) {
{ {
Caster: " Aria ", Caster: " Aria ",
Spell: " Cure Wounds ", Spell: " Cure Wounds ",
Effect: " Heals an injured ally. ",
NarrativeDescription: " Aria restores the fighter after the fight. ",
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
}, },
}} }}
@@ -122,9 +118,10 @@ func TestExtractPromptUsesCanonicalOverlayNamesWithoutAliasesOrMetadata(t *testi
"effective_catalog": metadata["catalog_digest"], "effective_catalog": metadata["catalog_digest"],
"prompt": metadata["prompt_sha256"], "prompt": metadata["prompt_sha256"],
"response_schema": metadata["response_schema_sha256"], "response_schema": metadata["response_schema_sha256"],
"npc_registry": checkpointFingerprintMap(newExtractor(t, &fakeSpellsLLMClient{}).CheckpointFingerprints())["npc_registry"],
} }
if len(fingerprints) != len(wantFingerprints) { if len(fingerprints) != len(wantFingerprints) {
t.Fatalf("checkpoint fingerprints = %#v, want prompt, response schema, and catalog identities", fingerprints) t.Fatalf("checkpoint fingerprints = %#v, want prompt, response schema, catalog, and NPC projection identities", fingerprints)
} }
for _, fingerprint := range fingerprints { for _, fingerprint := range fingerprints {
if want, ok := wantFingerprints[fingerprint.Name]; !ok || fingerprint.Value != want { if want, ok := wantFingerprints[fingerprint.Name]; !ok || fingerprint.Value != want {
@@ -267,9 +264,9 @@ func TestExtractRejectsInvalidRequests(t *testing.T) {
func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) { func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) {
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{ client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
{Caster: "Borin", Spell: "Fire Bolt", Effect: "Burns.", NarrativeDescription: "Second.", SourceRefs: responseSourceRefs(2, 2)}, {Caster: "Borin", Spell: "Fire Bolt", SourceRefs: responseSourceRefs(2, 2)},
{Caster: "Aria", Spell: "Cure Wounds", Effect: "Heals.", NarrativeDescription: "First.", SourceRefs: []spellSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(1), EndUnitID: shared.UnitRefFromInt(2)}, {StartUnitID: shared.UnitRefFromInt(1), EndUnitID: shared.UnitRefFromInt(2)}}}, {Caster: "Aria", Spell: "Cure Wounds", SourceRefs: []spellSourceRefResponse{{StartUnitID: 1, EndUnitID: 2}, {StartUnitID: 1, EndUnitID: 2}}},
{Caster: "Narrator", Spell: "Unknown", Effect: "Unknown.", NarrativeDescription: "Uncited."}, {Caster: "Narrator", Spell: "Unknown"},
}}} }}}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
if err != nil { if err != nil {
@@ -285,8 +282,8 @@ func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) {
func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) { func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) {
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{ client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{
Caster: "Aria", Spell: "Cure Wounds", Effect: "Heals.", NarrativeDescription: "Aria heals.", Caster: "Aria", Spell: "Cure Wounds",
SourceRefs: []spellSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(99), EndUnitID: shared.UnitRefFromString("missing")}}, SourceRefs: []spellSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
}}}} }}}}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
if err != nil { if err != nil {
@@ -297,3 +294,18 @@ func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) {
t.Fatalf("source ref = %#v, want canonical source with invalid range preserved", ref) t.Fatalf("source ref = %#v, want canonical source with invalid range preserved", ref)
} }
} }
func TestExtractMapsRawSemanticCandidatesWithoutRepair(t *testing.T) {
client := &fakeSpellsLLMClient{content: []byte(`{"spell_casts":[{"caster":"","spell":"Cure Wounds","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
if err != nil {
t.Fatalf("Extract() error = %v, want nil", err)
}
spell := result.Value.SpellCasts[0]
if spell.Caster != "" {
t.Fatalf("spell = %#v, want blank semantic values preserved", spell)
}
if refs := spell.SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 0, EndUnitID: -1}) {
t.Fatalf("source refs = %#v, want raw nonpositive candidates preserved", refs)
}
}

View File

@@ -1,7 +1,5 @@
package spells package spells
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
type extractionResponse struct { type extractionResponse struct {
SpellCasts []spellCastResponse `json:"spell_casts"` SpellCasts []spellCastResponse `json:"spell_casts"`
} }
@@ -9,12 +7,10 @@ type extractionResponse struct {
type spellCastResponse struct { type spellCastResponse struct {
Caster string `json:"caster"` Caster string `json:"caster"`
Spell string `json:"spell"` Spell string `json:"spell"`
Effect string `json:"effect"`
NarrativeDescription string `json:"narrative_description"`
SourceRefs []spellSourceRefResponse `json:"source_refs"` SourceRefs []spellSourceRefResponse `json:"source_refs"`
} }
type spellSourceRefResponse struct { type spellSourceRefResponse struct {
StartUnitID shared.UnitRef `json:"start_unit_id"` StartUnitID int `json:"start_unit_id"`
EndUnitID shared.UnitRef `json:"end_unit_id"` EndUnitID int `json:"end_unit_id"`
} }

View File

@@ -22,10 +22,9 @@ func TestSpellExtractorUsesExactUnboundNPCPromptAndOmitsRegistryIdentity(t *test
if metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil { if metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil {
t.Fatalf("unbound extractor metadata = %#v, want no NPC registry fields", metadata) t.Fatalf("unbound extractor metadata = %#v, want no NPC registry fields", metadata)
} }
for _, fingerprint := range extractor.CheckpointFingerprints() { fingerprints := checkpointFingerprintMap(extractor.CheckpointFingerprints())
if fingerprint.Name == "npc_registry" { if fingerprints["npc_registry"] == "" {
t.Fatalf("unbound checkpoint fingerprints = %#v, want no NPC registry fingerprint", extractor.CheckpointFingerprints()) t.Fatalf("unbound checkpoint fingerprints = %#v, want empty-projection identity", fingerprints)
}
} }
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}} client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
@@ -34,7 +33,7 @@ func TestSpellExtractorUsesExactUnboundNPCPromptAndOmitsRegistryIdentity(t *test
t.Fatalf("Extract() error = %v, want nil", err) t.Fatalf("Extract() error = %v, want nil", err)
} }
input := client.requests[0].Inputs[NPCRegistryReferenceSlot] input := client.requests[0].Inputs[NPCRegistryReferenceSlot]
if input.Name != NPCRegistryReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != "" || input.OriginURI != "" || string(input.Content) != `{"npcs":[]}` { if input.Name != NPCRegistryReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != fingerprints["npc_registry"] || input.OriginURI != "" || string(input.Content) != `{"npcs":[]}` {
t.Fatalf("NPC prompt input = %#v, want exact empty registry material", input) t.Fatalf("NPC prompt input = %#v, want exact empty registry material", input)
} }
} }
@@ -57,7 +56,7 @@ func TestSpellExtractorPreservesSemanticNPCRegistryFingerprintAndPromptWiring(t
t.Fatalf("semantic NPC fingerprints = %#v and %#v, want same npc_registry value", firstFingerprints, secondFingerprints) t.Fatalf("semantic NPC fingerprints = %#v and %#v, want same npc_registry value", firstFingerprints, secondFingerprints)
} }
metadata := first.ManifestMetadata() metadata := first.ManifestMetadata()
if metadata["npc_registry_digest"] != firstFingerprints["npc_registry"] || metadata["npc_count"] != 1 { if metadata["npc_registry_digest"] == "" || metadata["npc_registry_digest"] == firstFingerprints["npc_registry"] || metadata["npc_count"] != 1 {
t.Fatalf("NPC registry metadata = %#v, want digest and count only", metadata) t.Fatalf("NPC registry metadata = %#v, want digest and count only", metadata)
} }
@@ -70,8 +69,8 @@ func TestSpellExtractorPreservesSemanticNPCRegistryFingerprintAndPromptWiring(t
if input.Name != NPCRegistryReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != firstFingerprints["npc_registry"] || input.OriginURI != "" { if input.Name != NPCRegistryReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != firstFingerprints["npc_registry"] || input.OriginURI != "" {
t.Fatalf("NPC prompt input metadata = %#v, want semantic metadata without provenance", input) t.Fatalf("NPC prompt input metadata = %#v, want semantic metadata without provenance", input)
} }
if !bytes.Equal(input.Content, canonical) { if !bytes.Equal(input.Content, []byte(`{"npcs":[{"name":"Mira Thorn"}]}`)) {
t.Fatalf("NPC prompt input = %s, want canonical JSON %s", input.Content, canonical) t.Fatalf("NPC prompt input = %s, want names-only projection", input.Content)
} }
encoded, err := json.Marshal(map[string]any{"metadata": metadata, "fingerprints": firstFingerprints}) encoded, err := json.Marshal(map[string]any{"metadata": metadata, "fingerprints": firstFingerprints})
if err != nil { if err != nil {
@@ -97,16 +96,14 @@ func TestSpellExtractorResolvesOperationNPCOverrideWithoutSingletonMetadata(t *t
t.Fatalf("Extract() error = %v", err) t.Fatalf("Extract() error = %v", err)
} }
input := client.requests[0].Inputs[NPCRegistryReferenceSlot] input := client.requests[0].Inputs[NPCRegistryReferenceSlot]
if input.Digest == "" || string(input.Content) != string(canonical) || input.OriginURI != "" { if input.Digest == "" || string(input.Content) != `{"npcs":[{"name":"Mira Thorn"}]}` || input.OriginURI != "" {
t.Fatalf("operation NPC input = %#v, want generated canonical grounding without provenance", input) t.Fatalf("operation NPC input = %#v, want generated canonical grounding without provenance", input)
} }
if metadata := extractor.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil { if metadata := extractor.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil {
t.Fatalf("singleton metadata = %#v, want no operation-varying NPC identity", metadata) t.Fatalf("singleton metadata = %#v, want no operation-varying NPC identity", metadata)
} }
for _, fingerprint := range extractor.CheckpointFingerprints() { if checkpointFingerprintMap(extractor.CheckpointFingerprints())["npc_registry"] == "" {
if fingerprint.Name == "npc_registry" { t.Fatalf("singleton fingerprints = %#v, want empty-projection identity", extractor.CheckpointFingerprints())
t.Fatalf("singleton fingerprints = %#v, want no operation-varying NPC identity", extractor.CheckpointFingerprints())
}
} }
} }
@@ -114,11 +111,6 @@ func registryFixture() dnd.NPCList {
return dnd.NPCList{NPCs: []dnd.NPC{{ return dnd.NPCList{NPCs: []dnd.NPC{{
ID: identity.DeriveID("Mira Thorn"), ID: identity.DeriveID("Mira Thorn"),
Name: "Mira Thorn", Name: "Mira Thorn",
Aliases: []string{"The Greencloak"},
Description: "A guarded ranger who watches the northern road.",
Relationships: []dnd.NPCRelationship{{
Target: "Captain Vale", Relationship: "reports to",
}},
SourceRefs: []source.SourceRef{{SourceID: "npc-session", StartUnitID: 41, EndUnitID: 43}}, SourceRefs: []source.SourceRef{{SourceID: "npc-session", StartUnitID: 41, EndUnitID: 43}},
}}} }}}
} }

View File

@@ -20,6 +20,13 @@ func TestLoadResponseSchemaUsesExtractorOwnedLLMSchema(t *testing.T) {
if !strings.HasPrefix(schema.SHA256, "sha256:") || !json.Valid(schema.JSONSchema) { if !strings.HasPrefix(schema.SHA256, "sha256:") || !json.Valid(schema.JSONSchema) {
t.Fatalf("schema metadata = %#v, want valid hashed JSON", schema) t.Fatalf("schema metadata = %#v, want valid hashed JSON", schema)
} }
var schemaDocument map[string]any
if err := json.Unmarshal(schema.JSONSchema, &schemaDocument); err != nil {
t.Fatalf("Unmarshal(schema.JSONSchema) error = %v, want nil", err)
}
if schemaDocument["$id"] != ResponseSchemaID {
t.Fatalf("schema $id = %#v, want %q", schemaDocument["$id"], ResponseSchemaID)
}
valid := validSpellsResponse() valid := validSpellsResponse()
validJSON, err := json.Marshal(valid) validJSON, err := json.Marshal(valid)
@@ -29,6 +36,66 @@ func TestLoadResponseSchemaUsesExtractorOwnedLLMSchema(t *testing.T) {
if err := validateJSONSchema(validJSON, schema.JSONSchema); err != nil { if err := validateJSONSchema(validJSON, schema.JSONSchema); err != nil {
t.Fatalf("valid private spells response rejected: %v", err) t.Fatalf("valid private spells response rejected: %v", err)
} }
for _, test := range []struct {
name string
response map[string]any
valid bool
}{
{
name: "semantic blanks and empty evidence",
response: map[string]any{"spell_casts": []any{map[string]any{
"caster": "", "spell": "", "source_refs": []any{},
}}},
valid: true,
},
{
name: "nonpositive unit candidates",
response: map[string]any{"spell_casts": []any{map[string]any{
"caster": "Aria", "spell": "Cure Wounds",
"source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": -1}},
}}},
valid: true,
},
{
name: "missing required field",
response: map[string]any{"spell_casts": []any{map[string]any{
"spell": "Cure Wounds", "source_refs": []any{},
}}},
},
{
name: "unknown field",
response: map[string]any{"spell_casts": []any{map[string]any{
"caster": "Aria", "spell": "Cure Wounds", "source_refs": []any{}, "id": "assigned later",
}}},
},
{
name: "wrong field type",
response: map[string]any{"spell_casts": []any{map[string]any{
"caster": 7, "spell": "Cure Wounds", "source_refs": []any{},
}}},
},
{
name: "noninteger source identifier",
response: map[string]any{"spell_casts": []any{map[string]any{
"caster": "Aria", "spell": "Cure Wounds",
"source_refs": []any{map[string]any{"start_unit_id": 1.5, "end_unit_id": 2}},
}}},
},
} {
t.Run(test.name, func(t *testing.T) {
content, err := json.Marshal(test.response)
if err != nil {
t.Fatal(err)
}
err = validateJSONSchema(content, schema.JSONSchema)
if (err == nil) != test.valid {
t.Fatalf("validateJSONSchema() error = %v, want valid=%t", err, test.valid)
}
})
}
if err := validateJSONSchema([]byte(`{"spell_casts":`), schema.JSONSchema); err == nil {
t.Fatal("validateJSONSchema() error = nil, want malformed JSON rejected")
}
withCanonicalSourceID := validSpellsResponse() withCanonicalSourceID := validSpellsResponse()
withCanonicalSourceID["spell_casts"].([]any)[0].(map[string]any)["source_refs"].([]any)[0].(map[string]any)["source_id"] = "session-alpha" withCanonicalSourceID["spell_casts"].([]any)[0].(map[string]any)["source_refs"].([]any)[0].(map[string]any)["source_id"] = "session-alpha"
@@ -73,8 +140,6 @@ func validSpellsResponse() map[string]any {
map[string]any{ map[string]any{
"caster": "Aria", "caster": "Aria",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "The wounds close.",
"narrative_description": "Aria casts Cure Wounds.",
"source_refs": []any{ "source_refs": []any{
map[string]any{"start_unit_id": 1, "end_unit_id": 2}, map[string]any{"start_unit_id": 1, "end_unit_id": 2},
}, },

View File

@@ -25,7 +25,6 @@ var promptAssetManifest = shared.PromptAssetManifest{
"common-dnd-identity.md", "common-dnd-identity.md",
"common-dnd-transcript.md", "common-dnd-transcript.md",
"common-dnd-references.md", "common-dnd-references.md",
"common-dnd-immediate-resolution.md",
"common-dnd-npcs.md", "common-dnd-npcs.md",
}, },
} }

View File

@@ -8,7 +8,6 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog" spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
) )
@@ -76,8 +75,8 @@ func mustJSON(t *testing.T, value any) string {
func responseSourceRefs(startUnitID int, endUnitID int) []spellSourceRefResponse { func responseSourceRefs(startUnitID int, endUnitID int) []spellSourceRefResponse {
return []spellSourceRefResponse{ return []spellSourceRefResponse{
{ {
StartUnitID: shared.UnitRefFromInt(startUnitID), StartUnitID: startUnitID,
EndUnitID: shared.UnitRefFromInt(endUnitID), EndUnitID: endUnitID,
}, },
} }
} }
@@ -151,9 +150,13 @@ func (client *fakeSpellsLLMClient) CompleteStructured(_ context.Context, req con
if !ok { if !ok {
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target") return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target")
} }
*target = client.response
content := append([]byte(nil), client.content...) content := append([]byte(nil), client.content...)
if len(content) == 0 { if len(content) != 0 {
if err := json.Unmarshal(content, target); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
} else {
*target = client.response
var err error var err error
content, err = json.Marshal(client.response) content, err = json.Marshal(client.response)
if err != nil { if err != nil {

View File

@@ -22,9 +22,7 @@ const (
normalizationPolicy = "dnd.combat_turns.normalize.v1" normalizationPolicy = "dnd.combat_turns.normalize.v1"
NormalizationPolicy = normalizationPolicy NormalizationPolicy = normalizationPolicy
ReasonCodeFieldsNormalized = "combat_turn_fields_normalized"
ReasonCodeActorCanonicalized = "combat_actor_canonicalized" ReasonCodeActorCanonicalized = "combat_actor_canonicalized"
ReasonCodeTargetCanonicalized = "combat_target_canonicalized"
ReasonCodeSourceRefsNormalized = "source_references_normalized" ReasonCodeSourceRefsNormalized = "source_references_normalized"
ReasonCodeTurnsReordered = "combat_turns_reordered" ReasonCodeTurnsReordered = "combat_turns_reordered"
ReasonCodeDuplicateCollapsed = "duplicate_combat_turn_collapsed" ReasonCodeDuplicateCollapsed = "duplicate_combat_turn_collapsed"
@@ -92,9 +90,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
{Name: "identity_policy", Value: identity.Policy}, {Name: "identity_policy", Value: identity.Policy},
} }
seeded := n.npcResolver.Seeded() seeded := n.npcResolver.Seeded()
if seeded.Bound() { fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.ProjectionDigest()})
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.Digest()})
}
return fingerprints return fingerprints
} }
@@ -124,9 +120,7 @@ type normalizedRecord struct {
hasEvidence bool hasEvidence bool
} }
type targetCanonicalization struct { type actorCanonicalization struct {
actionIndex int
targetIndex int
from string from string
to string to string
} }
@@ -139,7 +133,7 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
records := make([]normalizedRecord, len(input.CombatTurns)) records := make([]normalizedRecord, len(input.CombatTurns))
warnings := make([]contracts.Warning, 0) warnings := make([]contracts.Warning, 0)
for index, inputTurn := range input.CombatTurns { for index, inputTurn := range input.CombatTurns {
turn, fieldsChanged, actorChange, targetChanges, refsChanged := normalizeTurn(inputTurn, registry) turn, actorChange, refsChanged := normalizeTurn(inputTurn, registry)
earliest, hasEvidence := earliestSourcePosition(doc, turn) earliest, hasEvidence := earliestSourcePosition(doc, turn)
records[index] = normalizedRecord{ records[index] = normalizedRecord{
turn: turn, turn: turn,
@@ -147,13 +141,6 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
earliest: earliest, earliest: earliest,
hasEvidence: hasEvidence, hasEvidence: hasEvidence,
} }
if fieldsChanged {
warnings = append(warnings, contracts.Warning{
Scope: turnScope(index),
ReasonCode: ReasonCodeFieldsNormalized,
Message: fmt.Sprintf("input index %d: combat turn fields normalized", index),
})
}
if actorChange != nil { if actorChange != nil {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: turnScope(index), Scope: turnScope(index),
@@ -162,15 +149,6 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
index, diagnostics.Quote(actorChange.from), diagnostics.Quote(actorChange.to)), index, diagnostics.Quote(actorChange.from), diagnostics.Quote(actorChange.to)),
}) })
} }
for _, targetChange := range targetChanges {
warnings = append(warnings, contracts.Warning{
Scope: turnScope(index),
ReasonCode: ReasonCodeTargetCanonicalized,
Message: fmt.Sprintf("input index %d: action %d target %d canonicalized from %s to %s",
index, targetChange.actionIndex, targetChange.targetIndex,
diagnostics.Quote(targetChange.from), diagnostics.Quote(targetChange.to)),
})
}
if refsChanged { if refsChanged {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: turnScope(index), Scope: turnScope(index),
@@ -207,87 +185,27 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
return dnd.CombatTurnList{CombatTurns: output}, warnings return dnd.CombatTurnList{CombatTurns: output}, warnings
} }
func normalizeTurn(input dnd.CombatTurn, registry *npcregistry.Registry) (dnd.CombatTurn, bool, *targetCanonicalization, []targetCanonicalization, bool) { func normalizeTurn(input dnd.CombatTurn, registry *npcregistry.Registry) (dnd.CombatTurn, *actorCanonicalization, bool) {
output := cloneCombatTurn(input) output := cloneCombatTurn(input)
output.Actor = identity.NormalizeDisplay(input.Actor) output.Actor = identity.NormalizeDisplay(input.Actor)
output.Summary = identity.NormalizeDisplay(input.Summary)
var actorChange *targetCanonicalization
if canonical, ok := registry.Lookup(output.Actor); ok { if canonical, ok := registry.Lookup(output.Actor); ok {
canonicalName := identity.NormalizeDisplay(canonical.Name) canonicalName := identity.NormalizeDisplay(canonical.Name)
if output.Actor != canonicalName {
actorChange = &targetCanonicalization{from: output.Actor, to: canonicalName}
output.Actor = canonicalName output.Actor = canonicalName
} }
var actorChange *actorCanonicalization
if input.Actor != output.Actor {
actorChange = &actorCanonicalization{from: input.Actor, to: output.Actor}
} }
targetChanges := make([]targetCanonicalization, 0)
for actionIndex := range output.Actions {
action := &output.Actions[actionIndex]
action.Declaration = identity.NormalizeDisplay(action.Declaration)
if action.Resolution != nil {
resolution := identity.NormalizeDisplay(*action.Resolution)
action.Resolution = &resolution
}
if action.Targets == nil {
continue
}
targets := make([]string, 0, len(action.Targets))
seen := make(map[string]struct{}, len(action.Targets))
for targetIndex, target := range action.Targets {
normalized := identity.NormalizeDisplay(target)
if canonical, ok := registry.Lookup(normalized); ok {
canonicalName := identity.NormalizeDisplay(canonical.Name)
if normalized != canonicalName {
targetChanges = append(targetChanges, targetCanonicalization{
actionIndex: actionIndex,
targetIndex: targetIndex,
from: normalized,
to: canonicalName,
})
}
normalized = canonicalName
}
key := identity.ComparisonKey(normalized)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
targets = append(targets, normalized)
}
action.Targets = targets
}
fieldsChanged := input.Actor != output.Actor || input.Summary != output.Summary
if len(input.Actions) != len(output.Actions) {
fieldsChanged = true
}
for index := range output.Actions {
if input.Actions[index].Declaration != output.Actions[index].Declaration ||
!stringSlicesEqual(input.Actions[index].Targets, output.Actions[index].Targets) ||
!stringPointersEqual(input.Actions[index].Resolution, output.Actions[index].Resolution) {
fieldsChanged = true
break
}
}
canonicalRefs, _, _ := canonicalizeSourceRefs(input.SourceRefs) canonicalRefs, _, _ := canonicalizeSourceRefs(input.SourceRefs)
output.SourceRefs = canonicalRefs output.SourceRefs = canonicalRefs
refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs) refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs)
return output, fieldsChanged, actorChange, targetChanges, refsChanged return output, actorChange, refsChanged
} }
func cloneCombatTurn(input dnd.CombatTurn) dnd.CombatTurn { func cloneCombatTurn(input dnd.CombatTurn) dnd.CombatTurn {
output := input output := input
if input.Round != nil {
round := *input.Round
output.Round = &round
}
if input.Actions != nil {
output.Actions = make([]dnd.CombatAction, len(input.Actions))
for index, action := range input.Actions {
output.Actions[index] = cloneCombatAction(action)
}
}
if input.SourceRefs != nil { if input.SourceRefs != nil {
output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs)) output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs))
copy(output.SourceRefs, input.SourceRefs) copy(output.SourceRefs, input.SourceRefs)
@@ -295,38 +213,6 @@ func cloneCombatTurn(input dnd.CombatTurn) dnd.CombatTurn {
return output return output
} }
func cloneCombatAction(input dnd.CombatAction) dnd.CombatAction {
output := input
if input.Targets != nil {
output.Targets = make([]string, len(input.Targets))
copy(output.Targets, input.Targets)
}
if input.Resolution != nil {
resolution := *input.Resolution
output.Resolution = &resolution
}
return output
}
func stringSlicesEqual(left, right []string) 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
}
func stringPointersEqual(left, right *string) bool {
if (left == nil) != (right == nil) {
return false
}
return left == nil || *left == *right
}
func sourceRefsEqual(left, right []source.SourceRef) bool { func sourceRefsEqual(left, right []source.SourceRef) bool {
if (left == nil) != (right == nil) || len(left) != len(right) { if (left == nil) != (right == nil) || len(left) != len(right) {
return false return false
@@ -456,12 +342,6 @@ func duplicateKey(turn dnd.CombatTurn, doc *source.SourceDocument) (string, bool
var key strings.Builder var key strings.Builder
writeKeyString(&key, identity.ComparisonKey(turn.Actor)) writeKeyString(&key, identity.ComparisonKey(turn.Actor))
writeKeyString(&key, string(turn.TurnKind)) writeKeyString(&key, string(turn.TurnKind))
if turn.Round == nil {
key.WriteByte('0')
} else {
key.WriteByte('1')
writeKeyInt(&key, *turn.Round)
}
for _, ref := range turn.SourceRefs { for _, ref := range turn.SourceRefs {
writeKeyString(&key, ref.SourceID) writeKeyString(&key, ref.SourceID)
writeKeyInt(&key, ref.StartUnitID) writeKeyInt(&key, ref.StartUnitID)
@@ -501,7 +381,7 @@ func turnScope(index int) string { return fmt.Sprintf("combat_turns[%d]", index)
func referenceSlots() []contracts.ReferenceSlot { func referenceSlots() []contracts.ReferenceSlot {
return []contracts.ReferenceSlot{{ return []contracts.ReferenceSlot{{
Name: NPCRegistryReferenceSlot, Name: NPCRegistryReferenceSlot,
Description: "Optional normalized NPC registry used for canonical actor and target grounding.", Description: "Optional normalized NPC registry used for canonical actor grounding.",
AcceptedMediaTypes: []string{"application/json"}, AcceptedMediaTypes: []string{"application/json"},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
MaxBytes: NPCRegistryMaxBytes, MaxBytes: NPCRegistryMaxBytes,

View File

@@ -24,17 +24,9 @@ func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("New() error = %v", err) t.Fatalf("New() error = %v", err)
} }
resolution := " the target is hit "
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: " storm ", Actor: " aria ",
TurnKind: dnd.CombatTurnKindTurn, TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{
Category: dnd.CombatActionCategoryAttack,
Declaration: " attacks\n with a sword ",
Targets: []string{" minion ", "goblin", " unknown combatant "},
Resolution: &resolution,
}},
Summary: " Aria\n attacks ",
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
}}} }}}
original := cloneCombatTurn(input.CombatTurns[0]) original := cloneCombatTurn(input.CombatTurns[0])
@@ -46,17 +38,14 @@ func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Normalize() error = %v", err) t.Fatalf("Normalize() error = %v", err)
} }
if got := result.Value.CombatTurns[0]; got.Actor != "Aria" || got.Summary != "Aria attacks" || got.Actions[0].Declaration != "attacks with a sword" || got.Actions[0].Resolution == nil || *got.Actions[0].Resolution != "the target is hit" { if got := result.Value.CombatTurns[0]; got.Actor != "Aria" {
t.Fatalf("normalized turn = %#v, want display-normalized fields", got) t.Fatalf("normalized turn = %#v, want canonical actor", got)
}
if got := result.Value.CombatTurns[0].Actions[0].Targets; !reflect.DeepEqual(got, []string{"Goblin", "unknown combatant"}) {
t.Fatalf("normalized targets = %#v, want canonical deduplicated target and preserved unmatched target", got)
} }
wantRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}} wantRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}
if !reflect.DeepEqual(result.Value.CombatTurns[0].SourceRefs, wantRefs) { if !reflect.DeepEqual(result.Value.CombatTurns[0].SourceRefs, wantRefs) {
t.Fatalf("normalized refs = %#v, want %#v", result.Value.CombatTurns[0].SourceRefs, wantRefs) t.Fatalf("normalized refs = %#v, want %#v", result.Value.CombatTurns[0].SourceRefs, wantRefs)
} }
for _, reason := range []string{ReasonCodeFieldsNormalized, ReasonCodeActorCanonicalized, ReasonCodeTargetCanonicalized, ReasonCodeSourceRefsNormalized} { for _, reason := range []string{ReasonCodeActorCanonicalized, ReasonCodeSourceRefsNormalized} {
if !hasWarningReason(result.Warnings, reason) { if !hasWarningReason(result.Warnings, reason) {
t.Fatalf("warnings = %#v, missing reason %q", result.Warnings, reason) t.Fatalf("warnings = %#v, missing reason %q", result.Warnings, reason)
} }
@@ -64,9 +53,9 @@ func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
if !reflect.DeepEqual(input.CombatTurns[0], original) { if !reflect.DeepEqual(input.CombatTurns[0], original) {
t.Fatalf("Normalize() mutated input: got %#v, want %#v", input.CombatTurns[0], original) t.Fatalf("Normalize() mutated input: got %#v, want %#v", input.CombatTurns[0], original)
} }
result.Value.CombatTurns[0].Actions[0].Targets[0] = "changed" result.Value.CombatTurns[0].SourceRefs[0].StartUnitID = 999
if input.CombatTurns[0].Actions[0].Targets[0] == "changed" { if input.CombatTurns[0].SourceRefs[0].StartUnitID == 999 {
t.Fatal("normalized targets share input storage") t.Fatal("normalized source refs share input storage")
} }
} }
@@ -77,10 +66,8 @@ func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin
t.Fatalf("New() error = %v", err) t.Fatalf("New() error = %v", err)
} }
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Storm", Actor: "aria",
TurnKind: dnd.CombatTurnKindTurn, TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "attacks", Targets: []string{"Minion"}}},
Summary: "Storm attacks",
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}},
}}} }}}
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{ result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{
@@ -92,8 +79,8 @@ func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin
t.Fatalf("Normalize() error = %v", err) t.Fatalf("Normalize() error = %v", err)
} }
turn := result.Value.CombatTurns[0] turn := result.Value.CombatTurns[0]
if turn.Actor != "Aria" || turn.Actions[0].Targets[0] != "Goblin" { if turn.Actor != "Aria" {
t.Fatalf("operation-normalized turn = %#v, want Aria/Goblin", turn) t.Fatalf("operation-normalized turn = %#v, want Aria", turn)
} }
if metadata := normalizer.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil { if metadata := normalizer.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil {
t.Fatalf("singleton metadata = %#v, want no operation-varying NPC identity", metadata) t.Fatalf("singleton metadata = %#v, want no operation-varying NPC identity", metadata)
@@ -103,12 +90,8 @@ func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin
func TestNormalizeOrdersBySourcePositionAndCollapsesExactDuplicates(t *testing.T) { func TestNormalizeOrdersBySourcePositionAndCollapsesExactDuplicates(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}, {ID: 10}, {ID: 90}}} doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}, {ID: 10}, {ID: 90}}}
first := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 50, EndUnitID: 50}) first := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 50, EndUnitID: 50})
first.Summary = "first record"
second := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 90, EndUnitID: 90}) second := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 90, EndUnitID: 90})
second.Summary = "later record"
duplicate := cloneCombatTurn(first) duplicate := cloneCombatTurn(first)
duplicate.Summary = "must not replace first"
duplicate.Actions[0].Declaration = "replacement action"
invalid := validTurn("Unknown", source.SourceRef{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999}) invalid := validTurn("Unknown", source.SourceRef{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999})
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{second, first, duplicate, invalid}} input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{second, first, duplicate, invalid}}
@@ -126,7 +109,7 @@ func TestNormalizeOrdersBySourcePositionAndCollapsesExactDuplicates(t *testing.T
if len(result.Value.CombatTurns) != 3 { if len(result.Value.CombatTurns) != 3 {
t.Fatalf("normalized turn count = %d, want 3", len(result.Value.CombatTurns)) t.Fatalf("normalized turn count = %d, want 3", len(result.Value.CombatTurns))
} }
if result.Value.CombatTurns[0].Summary != "first record" || result.Value.CombatTurns[0].Actions[0].Declaration != "Aria attacks" || result.Value.CombatTurns[1].Summary != "later record" || result.Value.CombatTurns[2].Actor != "Unknown" { if result.Value.CombatTurns[0].SourceRefs[0].StartUnitID != 50 || result.Value.CombatTurns[1].SourceRefs[0].StartUnitID != 90 || result.Value.CombatTurns[2].Actor != "Unknown" {
t.Fatalf("normalized order/value = %#v, want chronology then invalid evidence", result.Value.CombatTurns) t.Fatalf("normalized order/value = %#v, want chronology then invalid evidence", result.Value.CombatTurns)
} }
if !hasWarningReason(result.Warnings, ReasonCodeTurnsReordered) || !hasWarningReason(result.Warnings, ReasonCodeDuplicateCollapsed) { if !hasWarningReason(result.Warnings, ReasonCodeTurnsReordered) || !hasWarningReason(result.Warnings, ReasonCodeDuplicateCollapsed) {
@@ -164,14 +147,12 @@ func TestNormalizePreservesStableOrderForEqualEvidencePositions(t *testing.T) {
func TestNormalizeDoesNotCollapseDifferentIdentityDimensions(t *testing.T) { func TestNormalizeDoesNotCollapseDifferentIdentityDimensions(t *testing.T) {
doc := testDocument() doc := testDocument()
base := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}) base := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10})
base.Round = nil
tests := []struct { tests := []struct {
name string name string
other dnd.CombatTurn other dnd.CombatTurn
}{ }{
{name: "different actor", other: withActor(base, "Borin")}, {name: "different actor", other: withActor(base, "Borin")},
{name: "different turn kind", other: withKind(base, dnd.CombatTurnKindReaction)}, {name: "different turn kind", other: withKind(base, dnd.CombatTurnKindReaction)},
{name: "different round", other: withRound(base, 2)},
{name: "different evidence", other: withRef(base, source.SourceRef{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20})}, {name: "different evidence", other: withRef(base, source.SourceRef{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20})},
{name: "invalid evidence", other: withRef(base, source.SourceRef{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999})}, {name: "invalid evidence", other: withRef(base, source.SourceRef{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999})},
} }
@@ -222,7 +203,7 @@ func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T
if metadata := unbound.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil || metadata["normalization_policy"] != normalizationPolicy || metadata["identity_policy"] != identity.Policy { if metadata := unbound.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil || metadata["normalization_policy"] != normalizationPolicy || metadata["identity_policy"] != identity.Policy {
t.Fatalf("unbound metadata = %#v", metadata) t.Fatalf("unbound metadata = %#v", metadata)
} }
if got := unbound.CheckpointFingerprints(); len(got) != 2 || got[0].Name != "normalization_policy" || got[1].Name != "identity_policy" { if got := unbound.CheckpointFingerprints(); len(got) != 3 || got[0].Name != "normalization_policy" || got[1].Name != "identity_policy" || got[2].Name != "npc_registry" || got[2].Value == "" {
t.Fatalf("unbound fingerprints = %#v", got) t.Fatalf("unbound fingerprints = %#v", got)
} }
@@ -291,9 +272,6 @@ func validTurn(actor string, ref source.SourceRef) dnd.CombatTurn {
return dnd.CombatTurn{ return dnd.CombatTurn{
Actor: actor, Actor: actor,
TurnKind: dnd.CombatTurnKindTurn, TurnKind: dnd.CombatTurnKindTurn,
Round: intPointer(1),
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: actor + " attacks", Targets: []string{}, Resolution: nil}},
Summary: actor + " attacks",
SourceRefs: []source.SourceRef{ref}, SourceRefs: []source.SourceRef{ref},
} }
} }
@@ -305,8 +283,8 @@ func testDocument() *source.SourceDocument {
func npcReferences(t *testing.T) contracts.ReferenceSet { func npcReferences(t *testing.T) contracts.ReferenceSet {
t.Helper() t.Helper()
npcs := dnd.NPCList{NPCs: []dnd.NPC{ npcs := dnd.NPCList{NPCs: []dnd.NPC{
{ID: identity.DeriveID("Aria"), Name: "Aria", Aliases: []string{"Storm"}, Description: "a fighter", Relationships: []dnd.NPCRelationship{{Target: "Goblin", Relationship: "fights"}}, SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}}, {ID: identity.DeriveID("Aria"), Name: "Aria", SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}},
{ID: identity.DeriveID("Goblin"), Name: "Goblin", Aliases: []string{"Minion"}, Description: "a goblin", Relationships: []dnd.NPCRelationship{{Target: "Aria", Relationship: "fights"}}, SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}}, {ID: identity.DeriveID("Goblin"), Name: "Goblin", SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}},
}} }}
content, err := npccodec.New().Encode(npcs) content, err := npccodec.New().Encode(npcs)
if err != nil { if err != nil {
@@ -329,28 +307,17 @@ func hasWarningReason(warnings []contracts.Warning, reason string) bool {
return false return false
} }
func intPointer(value int) *int { return &value }
func withActor(turn dnd.CombatTurn, actor string) dnd.CombatTurn { func withActor(turn dnd.CombatTurn, actor string) dnd.CombatTurn {
turn.Actor = actor turn.Actor = actor
turn.Actions = cloneCombatTurn(turn).Actions
return turn return turn
} }
func withKind(turn dnd.CombatTurn, kind dnd.CombatTurnKind) dnd.CombatTurn { func withKind(turn dnd.CombatTurn, kind dnd.CombatTurnKind) dnd.CombatTurn {
turn.TurnKind = kind turn.TurnKind = kind
turn.Actions = cloneCombatTurn(turn).Actions
return turn
}
func withRound(turn dnd.CombatTurn, round int) dnd.CombatTurn {
turn.Round = intPointer(round)
turn.Actions = cloneCombatTurn(turn).Actions
return turn return turn
} }
func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn { func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn {
turn.SourceRefs = []source.SourceRef{ref} turn.SourceRefs = []source.SourceRef{ref}
turn.Actions = cloneCombatTurn(turn).Actions
return turn return turn
} }

View File

@@ -26,7 +26,6 @@ const (
ReasonCodeNPCIDRecomputed = "npc_id_recomputed" ReasonCodeNPCIDRecomputed = "npc_id_recomputed"
ReasonCodeSourceReferencesNormalized = "source_references_normalized" ReasonCodeSourceReferencesNormalized = "source_references_normalized"
ReasonCodeDuplicateNPCCollapsed = "duplicate_npc_collapsed" ReasonCodeDuplicateNPCCollapsed = "duplicate_npc_collapsed"
ReasonCodeRelationshipTargetCanonicalized = "relationship_target_canonicalized"
) )
var requiredCapabilities = []string{"merged"} var requiredCapabilities = []string{"merged"}
@@ -37,23 +36,17 @@ var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
type Options struct{} type Options struct{}
type Normalizer struct{} type Normalizer struct{}
func New(Options) *Normalizer { return &Normalizer{} } func New(Options) *Normalizer { return &Normalizer{} }
func (n *Normalizer) Key() string { return Key } func (n *Normalizer) Key() string { return Key }
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (n *Normalizer) ManifestMetadata() map[string]any { func (n *Normalizer) ManifestMetadata() map[string]any {
if n == nil { if n == nil {
return nil return nil
} }
return map[string]any{ return map[string]any{"identity_policy": identity.Policy, "normalization_policy": normalizationPolicy}
"identity_policy": identity.Policy,
"normalization_policy": normalizationPolicy,
}
} }
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
@@ -76,193 +69,106 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context error before normalize: %w", err) return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context error before normalize: %w", err)
} }
value, warnings := normalizeList(req.MergeOutput.Value) value, warnings := normalizeList(req.MergeOutput.Value)
return contracts.TypedNormalizeResult[dnd.NPCList]{Value: value, Warnings: warnings}, nil return contracts.TypedNormalizeResult[dnd.NPCList]{Value: value, Warnings: warnings}, nil
} }
type normalizedRecord struct { type normalizedRecord struct {
npc dnd.NPC npc dnd.NPC
fieldsChanged bool
referencesChanged bool
} }
func normalizeList(input dnd.NPCList) (dnd.NPCList, []contracts.Warning) { func normalizeList(input dnd.NPCList) (dnd.NPCList, []contracts.Warning) {
if input.NPCs == nil { if input.NPCs == nil {
return dnd.NPCList{}, nil return dnd.NPCList{}, nil
} }
records := make([]normalizedRecord, len(input.NPCs)) records := make([]normalizedRecord, len(input.NPCs))
warnings := make([]contracts.Warning, 0) warnings := make([]contracts.Warning, 0)
for index, inputNPC := range input.NPCs { for index, inputNPC := range input.NPCs {
npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC) npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC)
records[index] = normalizedRecord{ records[index] = normalizedRecord{npc: npc}
npc: npc,
fieldsChanged: fieldsChanged,
referencesChanged: referencesChanged,
}
if fieldsChanged { if fieldsChanged {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCFieldsNormalized, Message: fmt.Sprintf("input index %d: NPC name normalized for %s", index, diagnostics.Quote(inputNPC.Name))})
Scope: npcScope(index),
ReasonCode: ReasonCodeNPCFieldsNormalized,
Message: fmt.Sprintf("input index %d: NPC fields normalized for %s",
index, diagnostics.Quote(inputNPC.Name)),
})
} }
if referencesChanged { if referencesChanged {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputNPC.SourceRefs), len(npc.SourceRefs))})
Scope: npcScope(index),
ReasonCode: ReasonCodeSourceReferencesNormalized,
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
index, len(inputNPC.SourceRefs), len(npc.SourceRefs)),
})
} }
if inputNPC.ID != npc.ID { if inputNPC.ID != npc.ID {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCIDRecomputed, Message: fmt.Sprintf("input index %d: NPC ID recomputed from %s", index, diagnostics.Quote(npc.Name))})
Scope: npcScope(index),
ReasonCode: ReasonCodeNPCIDRecomputed,
Message: fmt.Sprintf("input index %d: NPC ID recomputed from %s",
index, diagnostics.Quote(npc.Name)),
})
} }
} }
components := identityComponents(records) groups := canonicalNameGroups(records)
output := dnd.NPCList{NPCs: make([]dnd.NPC, 0, len(components))} output := dnd.NPCList{NPCs: make([]dnd.NPC, 0, len(groups))}
retainedIndexes := make([]int, 0, len(components)) for _, members := range groups {
for _, members := range components { consolidated, referencesChanged := consolidate(records, members)
consolidated, sourceChanged := consolidate(records, members)
retainedIndex := members[0] retainedIndex := members[0]
output.NPCs = append(output.NPCs, consolidated) output.NPCs = append(output.NPCs, consolidated)
retainedIndexes = append(retainedIndexes, retainedIndex) if referencesChanged {
warnings = append(warnings, contracts.Warning{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized during identity consolidation (final count %d)", retainedIndex, len(consolidated.SourceRefs))})
if sourceChanged {
warnings = append(warnings, contracts.Warning{
Scope: npcScope(retainedIndex),
ReasonCode: ReasonCodeSourceReferencesNormalized,
Message: fmt.Sprintf("input index %d: source references normalized during identity consolidation (final count %d)",
retainedIndex, len(consolidated.SourceRefs)),
})
} }
if len(members) > 1 { if len(members) > 1 {
warnings = append(warnings, duplicateWarning(retainedIndex, members[1:])) warnings = append(warnings, duplicateWarning(retainedIndex, members[1:]))
} }
} }
warnings = append(warnings, canonicalizeRelationshipTargets(&output, retainedIndexes)...)
return output, warnings return output, warnings
} }
func normalizeRecord(input dnd.NPC) (dnd.NPC, bool, bool) { func normalizeRecord(input dnd.NPC) (dnd.NPC, bool, bool) {
output := cloneNPC(input) output := cloneNPC(input)
output.Name = identity.NormalizeDisplay(input.Name) output.Name = identity.NormalizeDisplay(input.Name)
output.Aliases = normalizeAliases(input.Aliases, output.Name)
output.Description = strings.TrimSpace(input.Description)
output.Relationships = normalizeRelationships(input.Relationships)
output.SourceRefs, _, _ = canonicalizeSourceRefs(input.SourceRefs) output.SourceRefs, _, _ = canonicalizeSourceRefs(input.SourceRefs)
output.ID = identity.DeriveID(output.Name) output.ID = identity.DeriveID(output.Name)
return output, input.Name != output.Name, !reflect.DeepEqual(input.SourceRefs, output.SourceRefs)
fieldsChanged := input.Name != output.Name ||
!reflect.DeepEqual(input.Aliases, output.Aliases) ||
input.Description != output.Description ||
!reflect.DeepEqual(input.Relationships, output.Relationships)
referencesChanged := !reflect.DeepEqual(input.SourceRefs, output.SourceRefs)
return output, fieldsChanged, referencesChanged
} }
func cloneNPC(input dnd.NPC) dnd.NPC { func cloneNPC(input dnd.NPC) dnd.NPC {
output := input input.SourceRefs = cloneSourceRefs(input.SourceRefs)
if input.Aliases != nil { return input
output.Aliases = make([]string, len(input.Aliases))
copy(output.Aliases, input.Aliases)
}
if input.Relationships != nil {
output.Relationships = make([]dnd.NPCRelationship, len(input.Relationships))
copy(output.Relationships, input.Relationships)
}
if input.SourceRefs != nil {
output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs))
copy(output.SourceRefs, input.SourceRefs)
}
return output
} }
func normalizeAliases(input []string, canonicalName string) []string { func canonicalNameGroups(records []normalizedRecord) [][]int {
if input == nil { groups := make([][]int, 0, len(records))
return nil ownerByKey := make(map[string]int, len(records))
} for index, record := range records {
key := identity.ComparisonKey(record.npc.Name)
canonicalKey := identity.ComparisonKey(canonicalName) if key != "" {
output := make([]string, 0, len(input)) if groupIndex, ok := ownerByKey[key]; ok {
seen := make(map[string]struct{}, len(input)) groups[groupIndex] = append(groups[groupIndex], index)
for _, alias := range input {
normalized := identity.NormalizeDisplay(alias)
key := identity.ComparisonKey(normalized)
if canonicalKey != "" && key == canonicalKey {
continue continue
} }
if _, exists := seen[key]; exists { ownerByKey[key] = len(groups)
continue
} }
seen[key] = struct{}{} groups = append(groups, []int{index})
output = append(output, normalized)
} }
return output return groups
} }
func normalizeRelationships(input []dnd.NPCRelationship) []dnd.NPCRelationship { func consolidate(records []normalizedRecord, members []int) (dnd.NPC, bool) {
if input == nil { output := cloneNPC(records[members[0]].npc)
return nil originalRefs := cloneSourceRefs(output.SourceRefs)
} for _, member := range members[1:] {
output.SourceRefs = append(output.SourceRefs, records[member].npc.SourceRefs...)
output := make([]dnd.NPCRelationship, 0, len(input))
seen := make(map[relationshipIdentity]struct{}, len(input))
for _, relationship := range input {
normalized := dnd.NPCRelationship{
Target: identity.NormalizeDisplay(relationship.Target),
Relationship: strings.TrimSpace(relationship.Relationship),
}
key := relationshipKey(normalized)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
output = append(output, normalized)
}
return output
}
type relationshipIdentity struct {
target string
relationship string
}
func relationshipKey(relationship dnd.NPCRelationship) relationshipIdentity {
return relationshipIdentity{
target: identity.ComparisonKey(relationship.Target),
relationship: identity.ComparisonKey(relationship.Relationship),
} }
output.SourceRefs, _, _ = canonicalizeSourceRefs(output.SourceRefs)
output.ID = identity.DeriveID(output.Name)
return output, !reflect.DeepEqual(originalRefs, output.SourceRefs)
} }
func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) { func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) {
if input == nil { if input == nil {
return nil, false, 0 return nil, false, 0
} }
canonical := cloneSourceRefs(input)
canonical := make([]source.SourceRef, len(input))
copy(canonical, input)
sort.SliceStable(canonical, func(left, right int) bool { sort.SliceStable(canonical, func(left, right int) bool {
return sourceRefLess(canonical[left], canonical[right]) if canonical[left].SourceID != canonical[right].SourceID {
return canonical[left].SourceID < canonical[right].SourceID
}
if canonical[left].StartUnitID != canonical[right].StartUnitID {
return canonical[left].StartUnitID < canonical[right].StartUnitID
}
return canonical[left].EndUnitID < canonical[right].EndUnitID
}) })
orderChanged := !reflect.DeepEqual(input, canonical)
orderChanged := false
for index := range input {
if input[index] != canonical[index] {
orderChanged = true
break
}
}
unique := make([]source.SourceRef, 0, len(canonical)) unique := make([]source.SourceRef, 0, len(canonical))
for _, ref := range canonical { for _, ref := range canonical {
if len(unique) == 0 || unique[len(unique)-1] != ref { if len(unique) == 0 || unique[len(unique)-1] != ref {
@@ -272,219 +178,11 @@ func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool,
return unique, orderChanged, len(input) - len(unique) return unique, orderChanged, len(input) - len(unique)
} }
func sourceRefLess(left, right source.SourceRef) bool {
if left.SourceID != right.SourceID {
return left.SourceID < right.SourceID
}
if left.StartUnitID != right.StartUnitID {
return left.StartUnitID < right.StartUnitID
}
return left.EndUnitID < right.EndUnitID
}
func identityComponents(records []normalizedRecord) [][]int {
parent := make([]int, len(records))
for index := range parent {
parent[index] = index
}
for left := 0; left < len(records); left++ {
for right := left + 1; right < len(records); right++ {
if recordsCanMerge(records[left].npc, records[right].npc) {
union(parent, left, right)
}
}
}
byRoot := make(map[int][]int, len(records))
for index := range records {
root := find(parent, index)
byRoot[root] = append(byRoot[root], index)
}
roots := make([]int, 0, len(byRoot))
for root := range byRoot {
roots = append(roots, root)
}
sort.Slice(roots, func(left, right int) bool {
return byRoot[roots[left]][0] < byRoot[roots[right]][0]
})
components := make([][]int, 0, len(roots))
for _, root := range roots {
components = append(components, byRoot[root])
}
return components
}
func recordsCanMerge(left, right dnd.NPC) bool {
leftCanonical := identity.ComparisonKey(left.Name)
rightCanonical := identity.ComparisonKey(right.Name)
return leftCanonical == rightCanonical ||
containsAliasKey(right.Aliases, leftCanonical) ||
containsAliasKey(left.Aliases, rightCanonical)
}
func containsAliasKey(aliases []string, wanted string) bool {
for _, alias := range aliases {
if identity.ComparisonKey(alias) == wanted {
return true
}
}
return false
}
func find(parent []int, index int) int {
for parent[index] != index {
parent[index] = parent[parent[index]]
index = parent[index]
}
return index
}
func union(parent []int, left, right int) {
leftRoot := find(parent, left)
rightRoot := find(parent, right)
if leftRoot == rightRoot {
return
}
if leftRoot < rightRoot {
parent[rightRoot] = leftRoot
} else {
parent[leftRoot] = rightRoot
}
}
func consolidate(records []normalizedRecord, members []int) (dnd.NPC, bool) {
output := cloneNPC(records[members[0]].npc)
originalRefs := cloneSourceRefs(output.SourceRefs)
canonicalKey := identity.ComparisonKey(output.Name)
for _, member := range members[1:] {
candidate := records[member].npc
appendAlias(&output.Aliases, candidate.Name, canonicalKey)
for _, alias := range candidate.Aliases {
appendAlias(&output.Aliases, alias, canonicalKey)
}
for _, relationship := range candidate.Relationships {
appendRelationship(&output.Relationships, relationship)
}
output.SourceRefs = append(output.SourceRefs, candidate.SourceRefs...)
}
output.SourceRefs, _, _ = canonicalizeSourceRefs(output.SourceRefs)
output.ID = identity.DeriveID(output.Name)
return output, !reflect.DeepEqual(originalRefs, output.SourceRefs)
}
func cloneSourceRefs(input []source.SourceRef) []source.SourceRef { func cloneSourceRefs(input []source.SourceRef) []source.SourceRef {
if input == nil { if input == nil {
return nil return nil
} }
output := make([]source.SourceRef, len(input)) return append([]source.SourceRef(nil), input...)
copy(output, input)
return output
}
func appendAlias(aliases *[]string, value, canonicalKey string) {
key := identity.ComparisonKey(value)
if canonicalKey != "" && key == canonicalKey {
return
}
for _, existing := range *aliases {
if identity.ComparisonKey(existing) == key {
return
}
}
*aliases = append(*aliases, value)
}
func appendRelationship(relationships *[]dnd.NPCRelationship, relationship dnd.NPCRelationship) {
key := relationshipKey(relationship)
for _, existing := range *relationships {
if relationshipKey(existing) == key {
return
}
}
*relationships = append(*relationships, relationship)
}
func canonicalizeRelationshipTargets(list *dnd.NPCList, retainedIndexes []int) []contracts.Warning {
if list == nil || len(list.NPCs) == 0 {
return nil
}
owners := make(map[string][]int)
for index, npc := range list.NPCs {
if key := identity.ComparisonKey(npc.Name); key != "" {
owners[key] = append(owners[key], index)
}
for _, alias := range npc.Aliases {
if key := identity.ComparisonKey(alias); key != "" {
owners[key] = appendUniqueIndex(owners[key], index)
}
}
}
warnings := make([]contracts.Warning, 0)
for outputIndex := range list.NPCs {
npc := &list.NPCs[outputIndex]
originalRelationshipCount := len(npc.Relationships)
for relationshipIndex := range npc.Relationships {
relationship := &npc.Relationships[relationshipIndex]
key := identity.ComparisonKey(relationship.Target)
if key == "" || len(owners[key]) != 1 {
continue
}
target := list.NPCs[owners[key][0]].Name
if relationship.Target == target {
continue
}
oldTarget := relationship.Target
relationship.Target = target
warnings = append(warnings, contracts.Warning{
Scope: npcScope(retainedIndexes[outputIndex]),
ReasonCode: ReasonCodeRelationshipTargetCanonicalized,
Message: fmt.Sprintf("input index %d: relationship target canonicalized from %s to %s",
retainedIndexes[outputIndex], diagnostics.Quote(oldTarget), diagnostics.Quote(target)),
})
}
npc.Relationships = deduplicateRelationships(npc.Relationships)
if len(npc.Relationships) != originalRelationshipCount {
warnings = append(warnings, contracts.Warning{
Scope: npcScope(retainedIndexes[outputIndex]),
ReasonCode: ReasonCodeNPCFieldsNormalized,
Message: fmt.Sprintf("input index %d: duplicate relationships removed after target canonicalization",
retainedIndexes[outputIndex]),
})
}
}
return warnings
}
func deduplicateRelationships(input []dnd.NPCRelationship) []dnd.NPCRelationship {
if input == nil {
return nil
}
output := make([]dnd.NPCRelationship, 0, len(input))
seen := make(map[relationshipIdentity]struct{}, len(input))
for _, relationship := range input {
key := relationshipKey(relationship)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
output = append(output, relationship)
}
return output
}
func appendUniqueIndex(values []int, wanted int) []int {
for _, value := range values {
if value == wanted {
return values
}
}
return append(values, wanted)
} }
func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
@@ -497,28 +195,17 @@ func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
for index, removedIndex := range displayed { for index, removedIndex := range displayed {
indices[index] = strconv.Itoa(removedIndex) indices[index] = strconv.Itoa(removedIndex)
} }
message := fmt.Sprintf("retained input index %d; removed input indices [%s]", retainedIndex, strings.Join(indices, ", ")) message := fmt.Sprintf("retained input index %d; removed input indices [%s]", retainedIndex, strings.Join(indices, ", "))
if omitted := len(removed) - len(displayed); omitted > 0 { if omitted := len(removed) - len(displayed); omitted > 0 {
message += fmt.Sprintf("; %d additional removed input indices omitted", omitted) message += fmt.Sprintf("; %d additional removed input indices omitted", omitted)
} }
return contracts.Warning{ return contracts.Warning{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeDuplicateNPCCollapsed, Message: message}
Scope: npcScope(retainedIndex),
ReasonCode: ReasonCodeDuplicateNPCCollapsed,
Message: message,
}
} }
func npcScope(index int) string { return fmt.Sprintf("npcs[%d]", index) } func npcScope(index int) string { return fmt.Sprintf("npcs[%d]", index) }
func ModuleSpec() pipeline.ModuleSpec { func ModuleSpec() pipeline.ModuleSpec {
return pipeline.ModuleSpec{ return pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ArtifactKind: dnd.NPCListKind}
Key: Key,
Stage: pipeline.StageNormalize,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.NPCListKind,
}
} }
func Register(registry *pipeline.NormalizerRegistry) error { func Register(registry *pipeline.NormalizerRegistry) error {
@@ -531,10 +218,7 @@ func Register(registry *pipeline.NormalizerRegistry) error {
}) })
} }
func validateOptions(options map[string]any) error { func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
_, err := DecodeOptions(options)
return err
}
func DecodeOptions(options map[string]any) (Options, error) { func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil { if err := pipeline.RejectUnknownOptions(options); err != nil {

View File

@@ -10,223 +10,91 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
identity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
) )
func TestModuleContractAndIdentity(t *testing.T) { func TestModuleContractAndIdentity(t *testing.T) {
if _, err := DecodeOptions(nil); err != nil { if _, err := DecodeOptions(nil); err != nil {
t.Fatalf("DecodeOptions(nil) error = %v, want nil", err) t.Fatalf("DecodeOptions(nil) error = %v, want nil", err)
} }
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") { if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
t.Fatalf("DecodeOptions() error = %v, want unknown option error", err) t.Fatal("DecodeOptions() accepted unknown option")
}
want := pipeline.ModuleSpec{
Key: Key,
Stage: pipeline.StageNormalize,
Requires: []string{"merged"},
Provides: []string{"normalized"},
ArtifactKind: dnd.NPCListKind,
} }
want := pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: dnd.NPCListKind}
if got := ModuleSpec(); !reflect.DeepEqual(got, want) { if got := ModuleSpec(); !reflect.DeepEqual(got, want) {
t.Fatalf("ModuleSpec() = %#v, want %#v", got, want) t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
} }
if slots := New(Options{}).ReferenceSlots(); slots != nil {
t.Fatalf("ReferenceSlots() = %#v, want nil", slots)
}
registry := pipeline.NewNormalizerRegistry() registry := pipeline.NewNormalizerRegistry()
if err := Register(registry); err != nil { if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v, want nil", err) t.Fatalf("Register() error = %v", err)
} }
if registered, ok := registry.SpecForArtifact(Key, dnd.NPCListKind); !ok || !reflect.DeepEqual(registered, want) {
t.Fatalf("registered spec = %#v, ok = %t, want %#v", registered, ok, want)
}
if err := Register(nil); err == nil || !strings.Contains(err.Error(), "normalizer registry") {
t.Fatalf("Register(nil) error = %v, want registry error", err)
}
normalizer := New(Options{}) normalizer := New(Options{})
metadata := normalizer.ManifestMetadata() if metadata := normalizer.ManifestMetadata(); metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy {
if metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy { t.Fatalf("metadata = %#v", metadata)
t.Fatalf("metadata = %#v, want identity and normalization policies", metadata)
} }
fingerprints := normalizer.CheckpointFingerprints() wantFingerprints := []pipeline.CheckpointFingerprint{{Name: "identity_policy", Value: identity.Policy}, {Name: "normalization_policy", Value: normalizationPolicy}}
wantFingerprints := []pipeline.CheckpointFingerprint{
{Name: "identity_policy", Value: identity.Policy},
{Name: "normalization_policy", Value: normalizationPolicy},
}
if !reflect.DeepEqual(fingerprints, wantFingerprints) {
t.Fatalf("fingerprints = %#v, want %#v", fingerprints, wantFingerprints)
}
fingerprints[0].Value = "changed"
if got := normalizer.CheckpointFingerprints(); !reflect.DeepEqual(got, wantFingerprints) { if got := normalizer.CheckpointFingerprints(); !reflect.DeepEqual(got, wantFingerprints) {
t.Fatalf("fingerprints were not defensive: %#v", got) t.Fatalf("fingerprints = %#v, want %#v", got, wantFingerprints)
} }
} }
func TestNormalizePerRecordFieldsAndEvidence(t *testing.T) { func TestNormalizeNamesEvidenceAndIDs(t *testing.T) {
input := dnd.NPCList{NPCs: []dnd.NPC{{ input := dnd.NPCList{NPCs: []dnd.NPC{{
ID: "wrong", ID: "wrong", Name: " Lady\tAsh ", SourceRefs: []source.SourceRef{
Name: " Lady\tAsh ",
Aliases: []string{" Ash ", " L.A. ", "l.a.", " Lady Ash "},
Description: " first description\n",
Relationships: []dnd.NPCRelationship{
{Target: " Lord\nOak ", Relationship: " friend "},
{Target: "lord oak", Relationship: "friend"},
},
SourceRefs: []source.SourceRef{
{SourceID: "b", StartUnitID: 2, EndUnitID: 3}, {SourceID: "b", StartUnitID: 2, EndUnitID: 3},
{SourceID: "a", StartUnitID: 4, EndUnitID: 4}, {SourceID: "a", StartUnitID: 4, EndUnitID: 4},
{SourceID: "b", StartUnitID: 2, EndUnitID: 3}, {SourceID: "b", StartUnitID: 2, EndUnitID: 3},
}, },
}}} }}}
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input)) result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input))
if err != nil { if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err) t.Fatalf("Normalize() error = %v", err)
} }
got := result.Value.NPCs[0] want := dnd.NPC{ID: identity.DeriveID("Lady Ash"), Name: "Lady Ash", SourceRefs: []source.SourceRef{{SourceID: "a", StartUnitID: 4, EndUnitID: 4}, {SourceID: "b", StartUnitID: 2, EndUnitID: 3}}}
if got.Name != "Lady Ash" || got.Description != "first description" { if !reflect.DeepEqual(result.Value.NPCs[0], want) {
t.Fatalf("normalized fields = %#v, want normalized display and description", got) t.Fatalf("NPC = %#v, want %#v", result.Value.NPCs[0], want)
} }
if !reflect.DeepEqual(got.Aliases, []string{"Ash", "L.A."}) { for _, reason := range []string{ReasonCodeNPCFieldsNormalized, ReasonCodeSourceReferencesNormalized, ReasonCodeNPCIDRecomputed} {
t.Fatalf("aliases = %#v, want canonical alias removal and deduplication", got.Aliases) if !hasWarning(result.Warnings, reason, "npcs[0]") {
t.Fatalf("warnings = %#v, want %s", result.Warnings, reason)
} }
wantRelationships := []dnd.NPCRelationship{{Target: "Lord Oak", Relationship: "friend"}}
if !reflect.DeepEqual(got.Relationships, wantRelationships) {
t.Fatalf("relationships = %#v, want %#v", got.Relationships, wantRelationships)
}
wantRefs := []source.SourceRef{
{SourceID: "a", StartUnitID: 4, EndUnitID: 4},
{SourceID: "b", StartUnitID: 2, EndUnitID: 3},
}
if !reflect.DeepEqual(got.SourceRefs, wantRefs) {
t.Fatalf("source refs = %#v, want %#v", got.SourceRefs, wantRefs)
}
if got.ID != identity.DeriveID("Lady Ash") {
t.Fatalf("ID = %q, want derived ID", got.ID)
}
if !hasWarning(result.Warnings, ReasonCodeNPCFieldsNormalized, "npcs[0]") ||
!hasWarning(result.Warnings, ReasonCodeSourceReferencesNormalized, "npcs[0]") ||
!hasWarning(result.Warnings, ReasonCodeNPCIDRecomputed, "npcs[0]") {
t.Fatalf("warnings = %#v, want field, source, and ID warnings", result.Warnings)
} }
} }
func TestNormalizeConsolidatesIdentityComponentsInStableOrder(t *testing.T) { func TestNormalizeConsolidatesCanonicalNamesOnlyAndUnionsEvidence(t *testing.T) {
input := dnd.NPCList{NPCs: []dnd.NPC{ input := dnd.NPCList{NPCs: []dnd.NPC{
{ {Name: " Captain Vale ", SourceRefs: []source.SourceRef{{SourceID: "a", StartUnitID: 1, EndUnitID: 1}}},
Name: " Captain Vale ", {Name: "captain vale", SourceRefs: []source.SourceRef{{SourceID: "b", StartUnitID: 2, EndUnitID: 2}}},
Description: "first description", {Name: "The Captain", SourceRefs: []source.SourceRef{{SourceID: "c", StartUnitID: 3, EndUnitID: 3}}},
Aliases: []string{"Vale"},
Relationships: []dnd.NPCRelationship{{Target: "Archivist", Relationship: "knows"}},
SourceRefs: []source.SourceRef{{SourceID: "a", StartUnitID: 1, EndUnitID: 1}},
},
{
Name: "Captain Vale",
Description: "second description",
Aliases: []string{"CV"},
SourceRefs: []source.SourceRef{{SourceID: "b", StartUnitID: 1, EndUnitID: 1}},
},
{
Name: "The Sage",
Description: "sage description",
Aliases: []string{"Archivist"},
SourceRefs: []source.SourceRef{{SourceID: "c", StartUnitID: 1, EndUnitID: 1}},
},
{
Name: "Archivist",
Description: "later sage description",
Aliases: []string{"Chronicler"},
SourceRefs: []source.SourceRef{{SourceID: "d", StartUnitID: 1, EndUnitID: 1}},
},
{
Name: "North",
Description: "north description",
SourceRefs: []source.SourceRef{{SourceID: "e", StartUnitID: 1, EndUnitID: 1}},
},
{
Name: "North Old",
Description: "old north description",
Aliases: []string{"North"},
SourceRefs: []source.SourceRef{{SourceID: "f", StartUnitID: 1, EndUnitID: 1}},
},
{
Name: "North Renamed",
Description: "renamed north description",
Aliases: []string{"North Old"},
SourceRefs: []source.SourceRef{{SourceID: "g", StartUnitID: 1, EndUnitID: 1}},
},
{Name: "Red", Description: "red", Aliases: []string{"Shared"}, SourceRefs: []source.SourceRef{{SourceID: "h", StartUnitID: 1, EndUnitID: 1}}},
{Name: "Blue", Description: "blue", Aliases: []string{"Shared"}, SourceRefs: []source.SourceRef{{SourceID: "i", StartUnitID: 1, EndUnitID: 1}}},
}} }}
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input)) result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input))
if err != nil { if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err) t.Fatalf("Normalize() error = %v", err)
} }
if got := len(result.Value.NPCs); got != 5 { if len(result.Value.NPCs) != 2 || result.Value.NPCs[0].Name != "Captain Vale" || result.Value.NPCs[1].Name != "The Captain" {
t.Fatalf("normalized NPC count = %d, want five components", got) t.Fatalf("NPCs = %#v, want name-only stable consolidation", result.Value.NPCs)
} }
if refs := result.Value.NPCs[0].SourceRefs; len(refs) != 2 || refs[0].SourceID != "a" || refs[1].SourceID != "b" {
first := result.Value.NPCs[0] t.Fatalf("source refs = %#v, want evidence union", refs)
if first.Name != "Captain Vale" || first.Description != "first description" || !reflect.DeepEqual(first.Aliases, []string{"Vale", "CV"}) {
t.Fatalf("first component = %#v, want first description and ordered aliases", first)
} }
if !reflect.DeepEqual(first.SourceRefs, []source.SourceRef{ if !hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[0]") {
{SourceID: "a", StartUnitID: 1, EndUnitID: 1}, t.Fatalf("warnings = %#v, want duplicate collapse", result.Warnings)
{SourceID: "b", StartUnitID: 1, EndUnitID: 1},
}) {
t.Fatalf("first provenance = %#v, want unioned refs", first.SourceRefs)
}
second := result.Value.NPCs[1]
if second.Name != "The Sage" || !reflect.DeepEqual(second.Aliases, []string{"Archivist", "Chronicler"}) || second.Description != "sage description" {
t.Fatalf("canonical-to-alias component = %#v, want consolidated sage", second)
}
if first.Relationships[0].Target != "The Sage" {
t.Fatalf("relationship target = %q, want The Sage", first.Relationships[0].Target)
}
if !hasWarning(result.Warnings, ReasonCodeRelationshipTargetCanonicalized, "npcs[0]") ||
!hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[0]") ||
!hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[2]") {
t.Fatalf("warnings = %#v, want collapse and target warnings", result.Warnings)
}
if got := result.Value.NPCs[2].Aliases; !reflect.DeepEqual(got, []string{"North Old", "North Renamed"}) {
t.Fatalf("transitive aliases = %#v, want ordered canonical members", got)
}
if result.Value.NPCs[3].Name != "Red" || result.Value.NPCs[4].Name != "Blue" {
t.Fatalf("shared-alias ordering = %#v, want Red then Blue", result.Value.NPCs[3:])
} }
} }
func TestNormalizePreservesInvalidEvidenceAndDoesNotAliasInput(t *testing.T) { func TestNormalizePreservesInvalidCandidatesAndDoesNotAliasInput(t *testing.T) {
invalid := dnd.NPCList{NPCs: []dnd.NPC{{ input := dnd.NPCList{NPCs: []dnd.NPC{{Name: " ", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 0, EndUnitID: -1}}}}}
Name: " ", before := dnd.NPCList{NPCs: []dnd.NPC{{Name: " ", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 0, EndUnitID: -1}}}}}
Aliases: []string{""}, result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input))
Description: " ", if err != nil || !reflect.DeepEqual(input, before) {
Relationships: []dnd.NPCRelationship{{Target: " ", Relationship: " "}}, t.Fatalf("Normalize() = %#v, %v; input mutated to %#v", result, err, input)
SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 9, EndUnitID: 9}},
}}}
original := cloneNPCListForTest(invalid)
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(invalid))
if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err)
} }
if !reflect.DeepEqual(invalid, original) { if result.Value.NPCs[0].Name != "" || result.Value.NPCs[0].SourceRefs[0].EndUnitID != -1 {
t.Fatalf("normalizer mutated input: got %#v, want %#v", invalid, original) t.Fatalf("candidate = %#v, want invalid semantics preserved", result.Value.NPCs[0])
} }
if result.Value.NPCs[0].Name != "" || result.Value.NPCs[0].Aliases[0] != "" || result.Value.NPCs[0].Relationships[0].Target != "" {
t.Fatalf("invalid evidence was unexpectedly removed: %#v", result.Value.NPCs[0])
}
result.Value.NPCs[0].Aliases[0] = "changed"
result.Value.NPCs[0].Relationships[0].Target = "changed"
result.Value.NPCs[0].SourceRefs[0].SourceID = "changed" result.Value.NPCs[0].SourceRefs[0].SourceID = "changed"
if invalid.NPCs[0].Aliases[0] != "" || invalid.NPCs[0].Relationships[0].Target != " " || invalid.NPCs[0].SourceRefs[0].SourceID != "source" { if input.NPCs[0].SourceRefs[0].SourceID != "source" {
t.Fatalf("output aliases input storage: input = %#v", invalid) t.Fatal("output aliases input evidence")
} }
} }
@@ -234,52 +102,17 @@ func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) {
normalizer := New(Options{}) normalizer := New(Options{})
result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCList{NPCs: nil})) result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCList{NPCs: nil}))
if err != nil || result.Value.NPCs != nil { if err != nil || result.Value.NPCs != nil {
t.Fatalf("nil list result = %#v, error = %v, want nil NPC slice", result.Value, err) t.Fatalf("nil list result = %#v, error = %v", result.Value, err)
} }
canceled, cancel := context.WithCancel(context.Background()) canceled, cancel := context.WithCancel(context.Background())
cancel() cancel()
if _, err := normalizer.Normalize(canceled, normalizeRequest(dnd.NPCList{})); err == nil || !strings.Contains(err.Error(), "context error") { if _, err := normalizer.Normalize(canceled, normalizeRequest(dnd.NPCList{})); err == nil || !strings.Contains(err.Error(), "context error") {
t.Fatalf("canceled Normalize() error = %v, want context error", err) t.Fatalf("canceled Normalize() error = %v", err)
}
if _, err := normalizer.Normalize(nil, normalizeRequest(dnd.NPCList{})); err == nil || !strings.Contains(err.Error(), "context must not be nil") {
t.Fatalf("nil context Normalize() error = %v, want context error", err)
}
var nilNormalizer *Normalizer
if _, err := nilNormalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCList{})); err == nil {
t.Fatal("nil normalizer Normalize() error = nil, want error")
}
}
func TestNormalizePreservesPresentEmptyAliases(t *testing.T) {
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(dnd.NPCList{NPCs: []dnd.NPC{{
Name: "Hooded Guard",
Aliases: []string{},
Description: "A distinguishable sentry.",
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}))
if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err)
}
if result.Value.NPCs[0].Aliases == nil || len(result.Value.NPCs[0].Aliases) != 0 {
t.Fatalf("aliases = %#v, want present empty array", result.Value.NPCs[0].Aliases)
} }
} }
func normalizeRequest(value dnd.NPCList) contracts.TypedNormalizeRequest[dnd.NPCList] { func normalizeRequest(value dnd.NPCList) contracts.TypedNormalizeRequest[dnd.NPCList] {
return contracts.TypedNormalizeRequest[dnd.NPCList]{ return contracts.TypedNormalizeRequest[dnd.NPCList]{MergeOutput: contracts.MergeArtifact[dnd.NPCList]{Value: value}}
MergeOutput: contracts.MergeArtifact[dnd.NPCList]{Value: value},
}
}
func cloneNPCListForTest(input dnd.NPCList) dnd.NPCList {
output := dnd.NPCList{}
if input.NPCs != nil {
output.NPCs = make([]dnd.NPC, len(input.NPCs))
for index, npc := range input.NPCs {
output.NPCs[index] = cloneNPC(npc)
}
}
return output
} }
func hasWarning(warnings []contracts.Warning, reason, scope string) bool { func hasWarning(warnings []contracts.Warning, reason, scope string) bool {

View File

@@ -266,12 +266,12 @@ func TestNormalizeCollapsesDuplicateGroupsAfterCanonicalization(t *testing.T) {
firstEvidence := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 2} firstEvidence := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 2}
secondEvidence := source.SourceRef{SourceID: "source", StartUnitID: 3, EndUnitID: 4} secondEvidence := source.SourceRef{SourceID: "source", StartUnitID: 3, EndUnitID: 4}
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{ input := dnd.SpellList{SpellCasts: []dnd.SpellCast{
{Caster: " Aria \t", Spell: " cure wounds ", Effect: "first effect", NarrativeDescription: "first narrative", SourceRefs: []source.SourceRef{secondEvidence, firstEvidence, firstEvidence}}, {Caster: " Aria \t", Spell: " cure wounds ", SourceRefs: []source.SourceRef{secondEvidence, firstEvidence, firstEvidence}},
{Caster: "Borin", Spell: "Healing Word", Effect: "distinct effect", NarrativeDescription: "distinct narrative", SourceRefs: []source.SourceRef{firstEvidence}}, {Caster: "Borin", Spell: "Healing Word", SourceRefs: []source.SourceRef{firstEvidence}},
{Caster: " yle ", Spell: "Cure Wounds", Effect: "kept effect", NarrativeDescription: "kept narrative", SourceRefs: []source.SourceRef{firstEvidence}}, {Caster: " yle ", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{firstEvidence}},
{Caster: "aria", Spell: " cure wounds ", Effect: "removed effect", NarrativeDescription: "removed narrative", SourceRefs: []source.SourceRef{firstEvidence, secondEvidence}}, {Caster: "aria", Spell: " cure wounds ", SourceRefs: []source.SourceRef{firstEvidence, secondEvidence}},
{Caster: "KYLE", Spell: " cure wounds ", Effect: "removed effect two", NarrativeDescription: "removed narrative two", SourceRefs: []source.SourceRef{firstEvidence}}, {Caster: "KYLE", Spell: " cure wounds ", SourceRefs: []source.SourceRef{firstEvidence}},
{Caster: " kyle ", Spell: "Cure Wounds", Effect: "removed effect three", NarrativeDescription: "removed narrative three", SourceRefs: []source.SourceRef{firstEvidence}}, {Caster: " kyle ", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{firstEvidence}},
}} }}
result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc)) result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
@@ -281,8 +281,8 @@ func TestNormalizeCollapsesDuplicateGroupsAfterCanonicalization(t *testing.T) {
if len(result.Value.SpellCasts) != 3 { if len(result.Value.SpellCasts) != 3 {
t.Fatalf("normalized casts = %#v, want first occurrences plus distinct cast", result.Value.SpellCasts) t.Fatalf("normalized casts = %#v, want first occurrences plus distinct cast", result.Value.SpellCasts)
} }
if got := result.Value.SpellCasts[0]; got.Caster != " Aria \t" || got.Effect != "first effect" || got.NarrativeDescription != "first narrative" { if got := result.Value.SpellCasts[0]; got.Caster != " Aria \t" {
t.Fatalf("retained first cast = %#v, want first occurrence fields unchanged", got) t.Fatalf("retained first cast = %#v, want first occurrence caster unchanged", got)
} }
if got := result.Value.SpellCasts[0].SourceRefs; !reflect.DeepEqual(got, []source.SourceRef{firstEvidence, secondEvidence}) { if got := result.Value.SpellCasts[0].SourceRefs; !reflect.DeepEqual(got, []source.SourceRef{firstEvidence, secondEvidence}) {
t.Fatalf("retained first evidence = %#v, want canonical first evidence only", got) t.Fatalf("retained first evidence = %#v, want canonical first evidence only", got)
@@ -395,8 +395,6 @@ func TestNormalizeIsIdempotentForAlreadyNormalizedInput(t *testing.T) {
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{ input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Spell: "Cure Wounds", Spell: "Cure Wounds",
Caster: "Aria", Caster: "Aria",
Effect: "effect",
NarrativeDescription: "narrative",
SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 1, EndUnitID: 2}}, SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 1, EndUnitID: 2}},
}}} }}}
normalizer := newNormalizer(t) normalizer := newNormalizer(t)

View File

@@ -7,8 +7,6 @@
{ {
"caster": " Aria ", "caster": " Aria ",
"spell": " cure wounds ", "spell": " cure wounds ",
"effect": "first effect",
"narrative_description": "first narrative",
"source_refs": [ "source_refs": [
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}, {"source_id": "source", "start_unit_id": 2, "end_unit_id": 2},
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1}, {"source_id": "source", "start_unit_id": 1, "end_unit_id": 1},
@@ -18,8 +16,6 @@
{ {
"caster": "aria", "caster": "aria",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "duplicate effect",
"narrative_description": "duplicate narrative",
"source_refs": [ "source_refs": [
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1}, {"source_id": "source", "start_unit_id": 1, "end_unit_id": 1},
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2} {"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
@@ -28,8 +24,6 @@
{ {
"caster": "aria", "caster": "aria",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "distinct effect",
"narrative_description": "distinct narrative",
"source_refs": [ "source_refs": [
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2} {"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
] ]
@@ -41,8 +35,6 @@
{ {
"caster": " Aria ", "caster": " Aria ",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "first effect",
"narrative_description": "first narrative",
"source_refs": [ "source_refs": [
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1}, {"source_id": "source", "start_unit_id": 1, "end_unit_id": 1},
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2} {"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
@@ -51,8 +43,6 @@
{ {
"caster": "aria", "caster": "aria",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "distinct effect",
"narrative_description": "distinct narrative",
"source_refs": [ "source_refs": [
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2} {"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
] ]
@@ -72,8 +62,6 @@
{ {
"caster": "Aria", "caster": "Aria",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "heals an ally",
"narrative_description": "Aria restores an ally's wounds.",
"source_refs": [ "source_refs": [
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1} {"source_id": "source", "start_unit_id": 1, "end_unit_id": 1}
] ]
@@ -85,8 +73,6 @@
{ {
"caster": "Aria", "caster": "Aria",
"spell": "Cure Wounds", "spell": "Cure Wounds",
"effect": "heals an ally",
"narrative_description": "Aria restores an ally's wounds.",
"source_refs": [ "source_refs": [
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1} {"source_id": "source", "start_unit_id": 1, "end_unit_id": 1}
] ]

View File

@@ -30,23 +30,16 @@ type IssueCode string
const ( const (
IssueEmptyCanonicalName IssueCode = "empty_canonical_name" IssueEmptyCanonicalName IssueCode = "empty_canonical_name"
IssueEmptyAlias IssueCode = "empty_alias"
IssueInvalidID IssueCode = "invalid_id" IssueInvalidID IssueCode = "invalid_id"
IssueIDMismatch IssueCode = "id_mismatch" IssueIDMismatch IssueCode = "id_mismatch"
IssueDuplicateCanonical IssueCode = "duplicate_canonical_identity" IssueDuplicateCanonical IssueCode = "duplicate_canonical_identity"
IssueDuplicateID IssueCode = "duplicate_id" IssueDuplicateID IssueCode = "duplicate_id"
IssueDuplicateAlias IssueCode = "duplicate_alias"
IssueOwnCanonicalAlias IssueCode = "alias_matches_canonical_name"
IssueAliasCanonicalCollision IssueCode = "alias_canonical_collision"
IssueAliasOwnershipCollision IssueCode = "alias_owned_by_multiple_records"
) )
// Issue is an inspectable identity validation problem. AliasIndex is -1 when // Issue is an inspectable identity validation problem.
// the issue applies to an NPC as a whole rather than a particular alias.
type Issue struct { type Issue struct {
Code IssueCode Code IssueCode
RecordIndex int RecordIndex int
AliasIndex int
Value string Value string
} }
@@ -107,88 +100,35 @@ func ValidID(value string) bool { return IsValidID(value) }
// It accepts the NPC slice used by typed pipeline artifacts. Use ValidateList // It accepts the NPC slice used by typed pipeline artifacts. Use ValidateList
// when the enclosing NPCList is more convenient at the call site. // when the enclosing NPCList is more convenient at the call site.
func ValidateRegistry(npcs []dnd.NPC) []Issue { func ValidateRegistry(npcs []dnd.NPC) []Issue {
type record struct {
canonical string
aliases []string
}
records := make([]record, len(npcs))
issues := make([]Issue, 0) issues := make([]Issue, 0)
canonicalOwners := make(map[string][]int) canonicalOwners := make(map[string][]int)
idOwners := make(map[string][]int) idOwners := make(map[string][]int)
aliasOwners := make(map[string][]int)
for recordIndex, npc := range npcs { for recordIndex, npc := range npcs {
canonical := ComparisonKey(npc.Name) canonical := ComparisonKey(npc.Name)
records[recordIndex].canonical = canonical
if canonical == "" { if canonical == "" {
issues = append(issues, Issue{Code: IssueEmptyCanonicalName, RecordIndex: recordIndex, AliasIndex: -1, Value: npc.Name}) issues = append(issues, Issue{Code: IssueEmptyCanonicalName, RecordIndex: recordIndex, Value: npc.Name})
} else { } else {
canonicalOwners[canonical] = append(canonicalOwners[canonical], recordIndex) canonicalOwners[canonical] = append(canonicalOwners[canonical], recordIndex)
} }
if !IsValidID(npc.ID) { if !IsValidID(npc.ID) {
issues = append(issues, Issue{Code: IssueInvalidID, RecordIndex: recordIndex, AliasIndex: -1, Value: npc.ID}) issues = append(issues, Issue{Code: IssueInvalidID, RecordIndex: recordIndex, Value: npc.ID})
} else if expected := DeriveID(npc.Name); npc.ID != expected { } else if expected := DeriveID(npc.Name); npc.ID != expected {
issues = append(issues, Issue{Code: IssueIDMismatch, RecordIndex: recordIndex, AliasIndex: -1, Value: npc.ID}) issues = append(issues, Issue{Code: IssueIDMismatch, RecordIndex: recordIndex, Value: npc.ID})
} }
if npc.ID != "" { if npc.ID != "" {
idOwners[npc.ID] = append(idOwners[npc.ID], recordIndex) idOwners[npc.ID] = append(idOwners[npc.ID], recordIndex)
} }
seenAliases := make(map[string]int, len(npc.Aliases))
for aliasIndex, alias := range npc.Aliases {
key := ComparisonKey(alias)
records[recordIndex].aliases = append(records[recordIndex].aliases, key)
if key == "" {
issues = append(issues, Issue{Code: IssueEmptyAlias, RecordIndex: recordIndex, AliasIndex: aliasIndex, Value: alias})
continue
}
if _, ok := seenAliases[key]; ok {
issues = append(issues, Issue{Code: IssueDuplicateAlias, RecordIndex: recordIndex, AliasIndex: aliasIndex, Value: alias})
} else {
seenAliases[key] = aliasIndex
}
if key == canonical {
issues = append(issues, Issue{Code: IssueOwnCanonicalAlias, RecordIndex: recordIndex, AliasIndex: aliasIndex, Value: alias})
}
aliasOwners[key] = append(aliasOwners[key], recordIndex)
}
} }
for recordIndex, record := range records { for recordIndex, npc := range npcs {
if record.canonical != "" && len(canonicalOwners[record.canonical]) > 1 && canonicalOwners[record.canonical][0] != recordIndex { canonical := ComparisonKey(npc.Name)
issues = append(issues, Issue{Code: IssueDuplicateCanonical, RecordIndex: recordIndex, AliasIndex: -1, Value: npcs[recordIndex].Name}) if canonical != "" && len(canonicalOwners[canonical]) > 1 && canonicalOwners[canonical][0] != recordIndex {
} issues = append(issues, Issue{Code: IssueDuplicateCanonical, RecordIndex: recordIndex, Value: npc.Name})
if id := npcs[recordIndex].ID; id != "" && len(idOwners[id]) > 1 && idOwners[id][0] != recordIndex {
issues = append(issues, Issue{Code: IssueDuplicateID, RecordIndex: recordIndex, AliasIndex: -1, Value: id})
}
}
seenAliasKeys := make(map[string]struct{})
for _, record := range records {
for _, alias := range record.aliases {
if alias == "" {
continue
}
if _, alreadyProcessed := seenAliasKeys[alias]; alreadyProcessed {
continue
}
seenAliasKeys[alias] = struct{}{}
owners := uniqueIndexes(aliasOwners[alias])
if len(owners) > 1 {
for _, recordIndex := range owners {
issues = append(issues, Issue{Code: IssueAliasOwnershipCollision, RecordIndex: recordIndex, AliasIndex: aliasIndexFor(records[recordIndex].aliases, alias), Value: alias})
}
}
for _, recordIndex := range owners {
for _, canonicalOwner := range canonicalOwners[alias] {
if canonicalOwner != recordIndex {
issues = append(issues, Issue{Code: IssueAliasCanonicalCollision, RecordIndex: recordIndex, AliasIndex: aliasIndexFor(records[recordIndex].aliases, alias), Value: alias})
break
}
}
} }
if npc.ID != "" && len(idOwners[npc.ID]) > 1 && idOwners[npc.ID][0] != recordIndex {
issues = append(issues, Issue{Code: IssueDuplicateID, RecordIndex: recordIndex, Value: npc.ID})
} }
} }
@@ -201,28 +141,6 @@ func ValidateList(list dnd.NPCList) []Issue { return ValidateRegistry(list.NPCs)
// Validate is a convenience alias for ValidateList. // Validate is a convenience alias for ValidateList.
func Validate(list dnd.NPCList) []Issue { return ValidateList(list) } func Validate(list dnd.NPCList) []Issue { return ValidateList(list) }
func uniqueIndexes(values []int) []int {
seen := make(map[int]struct{}, len(values))
unique := make([]int, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
unique = append(unique, value)
}
return unique
}
func aliasIndexFor(aliases []string, key string) int {
for index, alias := range aliases {
if alias == key {
return index
}
}
return -1
}
// Error makes an issue useful in simple callers while preserving its // Error makes an issue useful in simple callers while preserving its
// structured fields for aggregate diagnostics. // structured fields for aggregate diagnostics.
func (i Issue) Error() string { func (i Issue) Error() string {

View File

@@ -77,18 +77,13 @@ func TestIdentityFunctionsAreSafeForConcurrentUse(t *testing.T) {
func TestValidateRegistryReportsIdentityCollisionCategories(t *testing.T) { func TestValidateRegistryReportsIdentityCollisionCategories(t *testing.T) {
validID := DeriveID("Mira Thorn") validID := DeriveID("Mira Thorn")
npcs := []dnd.NPC{ npcs := []dnd.NPC{
{ID: validID, Name: "Mira Thorn", Aliases: []string{"The Greencloak", "the greencloak", "Mira Thorn"}}, {ID: validID, Name: "Mira Thorn"},
{ID: validID, Name: "Mira Thorn", Aliases: []string{"The Greencloak"}}, {ID: validID, Name: "Mira Thorn"},
{ID: DeriveID("Captain Vale"), Name: "Captain Vale", Aliases: []string{"Mira Thorn"}},
} }
issues := ValidateRegistry(npcs) issues := ValidateRegistry(npcs)
want := map[IssueCode]bool{ want := map[IssueCode]bool{
IssueDuplicateAlias: false,
IssueOwnCanonicalAlias: false,
IssueDuplicateCanonical: false, IssueDuplicateCanonical: false,
IssueDuplicateID: false, IssueDuplicateID: false,
IssueAliasOwnershipCollision: false,
IssueAliasCanonicalCollision: false,
} }
for _, issue := range issues { for _, issue := range issues {
if _, ok := want[issue.Code]; ok { if _, ok := want[issue.Code]; ok {

View File

@@ -5,6 +5,7 @@ package registry
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"mime" "mime"
"strings" "strings"
@@ -31,6 +32,7 @@ type Registry struct {
list dnd.NPCList list dnd.NPCList
canonical []byte canonical []byte
digest string digest string
projectionDigest string
promptInput contracts.LLMInputMaterial promptInput contracts.LLMInputMaterial
lookupByKey map[string]int lookupByKey map[string]int
} }
@@ -141,10 +143,12 @@ func Resolve(references contracts.ReferenceSet) (*Registry, error) {
slot, ok := references.Slots[ReferenceSlot] slot, ok := references.Slots[ReferenceSlot]
if !ok { if !ok {
content := []byte(emptyPrompt) content := []byte(emptyPrompt)
projectionDigest := semanticDigest(content)
return &Registry{ return &Registry{
list: dnd.NPCList{NPCs: []dnd.NPC{}}, list: dnd.NPCList{NPCs: []dnd.NPC{}},
canonical: append([]byte(nil), content...), canonical: append([]byte(nil), content...),
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, "", ""), projectionDigest: projectionDigest,
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
lookupByKey: map[string]int{}, lookupByKey: map[string]int{},
}, nil }, nil
} }
@@ -178,20 +182,23 @@ func Resolve(references contracts.ReferenceSet) (*Registry, error) {
} }
list := cloneNPCList(value) list := cloneNPCList(value)
lookupByKey := make(map[string]int, len(list.NPCs)*2) lookupByKey := make(map[string]int, len(list.NPCs))
for index, npc := range list.NPCs { for index, npc := range list.NPCs {
lookupByKey[identity.ComparisonKey(npc.Name)] = index lookupByKey[identity.ComparisonKey(npc.Name)] = index
for _, alias := range npc.Aliases {
lookupByKey[identity.ComparisonKey(alias)] = index
}
} }
digest := semanticDigest(content) digest := semanticDigest(content)
projection, err := nameProjection(list)
if err != nil {
return nil, fmt.Errorf("encode NPC name projection: %w", err)
}
projectionDigest := semanticDigest(projection)
return &Registry{ return &Registry{
bound: true, bound: true,
list: list, list: list,
canonical: append([]byte(nil), content...), canonical: append([]byte(nil), content...),
digest: digest, digest: digest,
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, digest, ""), projectionDigest: projectionDigest,
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""),
lookupByKey: lookupByKey, lookupByKey: lookupByKey,
}, nil }, nil
} }
@@ -235,6 +242,15 @@ func (r *Registry) Digest() string {
return r.digest return r.digest
} }
// ProjectionDigest returns the SHA-256 digest of the exact names-only prompt
// projection, including for an unbound or empty registry.
func (r *Registry) ProjectionDigest() string {
if r == nil {
return ""
}
return r.projectionDigest
}
// Count returns the number of validated NPC records. // Count returns the number of validated NPC records.
func (r *Registry) Count() int { func (r *Registry) Count() int {
if r == nil { if r == nil {
@@ -243,8 +259,8 @@ func (r *Registry) Count() int {
return len(r.list.NPCs) return len(r.list.NPCs)
} }
// PromptInput returns the canonical registry as a content-safe prompt input. // PromptInput returns the names-only registry projection as a content-safe
// Reference provenance is deliberately omitted. // prompt input. Durable IDs, evidence, and reference provenance are omitted.
func (r *Registry) PromptInput() contracts.LLMInputMaterial { func (r *Registry) PromptInput() contracts.LLMInputMaterial {
if r == nil { if r == nil {
return contracts.LLMInputMaterial{} return contracts.LLMInputMaterial{}
@@ -252,8 +268,8 @@ func (r *Registry) PromptInput() contracts.LLMInputMaterial {
return r.promptInput.Clone() return r.promptInput.Clone()
} }
// Lookup returns the canonical NPC for an exact canonical-name or alias match // Lookup returns the canonical NPC for an exact canonical-name match under the
// under the NPC identity comparison policy. // NPC identity comparison policy.
func (r *Registry) Lookup(value string) (dnd.NPC, bool) { func (r *Registry) Lookup(value string) (dnd.NPC, bool) {
if r == nil { if r == nil {
return dnd.NPC{}, false return dnd.NPC{}, false
@@ -270,13 +286,26 @@ func semanticDigest(content []byte) string {
return "sha256:" + hex.EncodeToString(sum[:]) return "sha256:" + hex.EncodeToString(sum[:])
} }
type projectedNPC struct {
Name string `json:"name"`
}
type projectedNPCList struct {
NPCs []projectedNPC `json:"npcs"`
}
func nameProjection(list dnd.NPCList) ([]byte, error) {
projection := projectedNPCList{NPCs: make([]projectedNPC, len(list.NPCs))}
for index, npc := range list.NPCs {
projection.NPCs[index] = projectedNPC{Name: npc.Name}
}
return json.Marshal(projection)
}
func formatIdentityIssues(issues []identity.Issue) string { func formatIdentityIssues(issues []identity.Issue) string {
parts := make([]string, len(issues)) parts := make([]string, len(issues))
for index, issue := range issues { for index, issue := range issues {
location := fmt.Sprintf("record %d", issue.RecordIndex) location := fmt.Sprintf("record %d", issue.RecordIndex)
if issue.AliasIndex >= 0 {
location += fmt.Sprintf(" alias %d", issue.AliasIndex)
}
parts[index] = fmt.Sprintf("%s at %s", issue.Code, location) parts[index] = fmt.Sprintf("%s at %s", issue.Code, location)
} }
return diagnostics.Aggregate("validate NPC registry identity", parts) return diagnostics.Aggregate("validate NPC registry identity", parts)
@@ -298,8 +327,6 @@ func cloneNPCs(values []dnd.NPC) []dnd.NPC {
} }
func cloneNPC(value dnd.NPC) dnd.NPC { func cloneNPC(value dnd.NPC) dnd.NPC {
value.Aliases = append([]string(nil), value.Aliases...)
value.Relationships = append([]dnd.NPCRelationship(nil), value.Relationships...)
value.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...) value.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
return value return value
} }

View File

@@ -2,313 +2,161 @@ package registry
import ( import (
"bytes" "bytes"
"encoding/json" "reflect"
"fmt"
"strings" "strings"
"sync"
"testing" "testing"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" 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/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
) )
func TestResolveAbsentRegistryUsesExactEmptyPrompt(t *testing.T) { func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) {
resolved, err := Resolve(contracts.ReferenceSet{}) registry, err := Resolve(contracts.ReferenceSet{})
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v, want nil", err) t.Fatalf("Resolve() error = %v", err)
} }
if resolved.Bound() || resolved.Digest() != "" || resolved.Count() != 0 { input := registry.PromptInput()
t.Fatalf("resolved unbound registry = %#v, want no semantic metadata", resolved) if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt {
t.Fatalf("registry = %#v input = %#v, want unbound empty registry", registry, input)
} }
input := resolved.PromptInput() if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" {
if input.Name != ReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != "" || input.OriginURI != "" { t.Fatalf("projection digest/input = %q/%#v", registry.ProjectionDigest(), input)
t.Fatalf("unbound prompt input metadata = %#v, want name/media type only", input)
}
if got := string(input.Content); got != emptyPrompt {
t.Fatalf("unbound prompt input = %q, want exact empty registry", got)
}
if got := string(resolved.CanonicalBytes()); got != emptyPrompt {
t.Fatalf("unbound canonical bytes = %q, want exact empty registry", got)
} }
} }
func TestResolveCanonicalizesAndProvidesSemanticIdentity(t *testing.T) { func TestResolveKeepsDurableProvenanceAndProjectsOnlyOrderedNames(t *testing.T) {
value := validRegistryList() list := registryFixture()
canonical := encodeRegistry(t, value) registry := resolveList(t, list)
raw := append([]byte(" \n"), canonical...) if !registry.Bound() || registry.Digest() == "" || registry.Count() != 2 {
raw = append(raw, []byte("\n ")...) t.Fatalf("registry identity = bound %t digest %q count %d", registry.Bound(), registry.Digest(), registry.Count())
}
if got := string(registry.PromptInput().Content); got != `{"npcs":[{"name":"Mira Thorn"},{"name":"Captain Vale"}]}` {
t.Fatalf("prompt projection = %s", got)
}
for _, forbidden := range []string{"npc:sha256:", "source_refs", "source_id", "session-alpha"} {
if strings.Contains(string(registry.PromptInput().Content), forbidden) {
t.Fatalf("projection leaked %q: %s", forbidden, registry.PromptInput().Content)
}
}
if registry.PromptInput().Digest != registry.ProjectionDigest() || registry.Digest() == registry.ProjectionDigest() {
t.Fatalf("full/projection digests = %q/%q", registry.Digest(), registry.ProjectionDigest())
}
}
resolved, err := Resolve(registryReference(raw, "file:///another-session/npcs.json")) func TestNameProjectionDigestTracksOnlyNamesAndOrder(t *testing.T) {
base := registryFixture()
evidenceChanged := registryFixture()
evidenceChanged.NPCs[0].ID = "different application id"
evidenceChanged.NPCs[0].SourceRefs = []source.SourceRef{{SourceID: "other", StartUnitID: 40, EndUnitID: 41}}
baseBytes, err := nameProjection(base)
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v, want nil", err) t.Fatal(err)
} }
if !resolved.Bound() || resolved.Count() != len(value.NPCs) { changedBytes, err := nameProjection(evidenceChanged)
t.Fatalf("resolved registry = %#v, want bound registry with %d NPC", resolved, len(value.NPCs))
}
if !bytes.Equal(resolved.CanonicalBytes(), canonical) || !bytes.Equal(resolved.PromptInput().Content, canonical) {
t.Fatalf("canonical content = %s, want %s", resolved.CanonicalBytes(), canonical)
}
if resolved.PromptInput().Digest != resolved.Digest() || !strings.HasPrefix(resolved.Digest(), "sha256:") {
t.Fatalf("semantic digest = %q, want SHA-256 digest", resolved.Digest())
}
if resolved.PromptInput().OriginURI != "" {
t.Fatalf("prompt input origin = %q, want no provenance path", resolved.PromptInput().OriginURI)
}
}
func TestResolveRejectsInvalidBoundaryValuesWithoutContent(t *testing.T) {
valid := validRegistryList()
second := valid.NPCs[0]
second.ID = identity.DeriveID("Captain Vale")
second.Name = "Captain Vale"
second.Aliases = []string{"The Greencloak"}
valueWithAliasCollision := dnd.NPCList{NPCs: []dnd.NPC{valid.NPCs[0], second}}
invalidID := valid
invalidID.NPCs[0].ID = "not-an-npc-id"
tests := []struct {
name string
reference contracts.ReferenceSet
wantError string
forbidden []string
}{
{name: "zero items", reference: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {Items: []contracts.ReferenceItem{}}}}, wantError: "exactly one"},
{name: "multiple", reference: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {Items: []contracts.ReferenceItem{{Content: []byte(emptyPrompt)}, {Content: []byte(emptyPrompt)}}}}}, wantError: "exactly one"},
{name: "wrong media type", reference: registryReferenceWithMedia([]byte(emptyPrompt), "text/plain"), wantError: "must be application/json"},
{name: "malformed JSON", reference: registryReference([]byte(`{"npcs":[],"MALFORMED_REGISTRY_SECRET":`), "file:///private.json"), wantError: "invalid approved NPC JSON", forbidden: []string{"MALFORMED_REGISTRY_SECRET"}},
{name: "unknown field", reference: registryReference([]byte(`{"npcs":[],"UNKNOWN_FIELD_SECRET":true}`), "file:///private.json"), wantError: "invalid approved NPC JSON", forbidden: []string{"UNKNOWN_FIELD_SECRET"}},
{name: "invalid ID", reference: registryReference(marshalRegistry(t, invalidID), "file:///private.json"), wantError: "decode NPC registry"},
{name: "alias collision", reference: registryReference(encodeRegistry(t, valueWithAliasCollision), "file:///private.json"), wantError: string(identity.IssueAliasOwnershipCollision)},
{name: "byte limit", reference: registryReference(bytes.Repeat([]byte("x"), MaxBytes+1), "file:///private.json"), wantError: "limit"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := Resolve(test.reference)
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("Resolve() error = %v, want %q", err, test.wantError)
}
for _, forbidden := range append(test.forbidden, "Mira Thorn", "The Greencloak", "private.json") {
if strings.Contains(err.Error(), forbidden) {
t.Fatalf("error leaked registry content or provenance %q: %v", forbidden, err)
}
}
})
}
}
func TestResolveBoundsIdentityDiagnosticsWithoutContent(t *testing.T) {
const recordCount = 30
value := dnd.NPCList{NPCs: make([]dnd.NPC, recordCount)}
for index := range value.NPCs {
value.NPCs[index] = dnd.NPC{
ID: "npc:sha256:0000000000000000000000000000000000000000000000000000000000000000",
Name: fmt.Sprintf("PRIVATE NPC %d", index),
Aliases: []string{"PRIVATE SHARED ALIAS"},
Description: "PRIVATE DESCRIPTION",
Relationships: []dnd.NPCRelationship{},
SourceRefs: []source.SourceRef{{SourceID: "private-source", StartUnitID: 1, EndUnitID: 1}},
}
}
issues := identity.ValidateList(value)
if len(issues) <= diagnostics.MaxIssues {
t.Fatalf("identity issues = %d, want more than display limit", len(issues))
}
_, err := Resolve(registryReference(marshalRegistry(t, value), "file:///private-registry.json"))
if err == nil {
t.Fatal("Resolve() error = nil, want bounded identity rejection")
}
message := err.Error()
if !utf8.ValidString(message) || len([]byte(message)) > diagnostics.MaxMessageBytes {
t.Fatalf("identity error has invalid encoding or size: bytes=%d message=%q", len([]byte(message)), message)
}
wantOmitted := fmt.Sprintf("%d additional issue(s) omitted", len(issues)-diagnostics.MaxIssues)
if !strings.Contains(message, wantOmitted) {
t.Fatalf("identity error = %q, want %q", message, wantOmitted)
}
for _, forbidden := range []string{"PRIVATE NPC", "PRIVATE SHARED ALIAS", "PRIVATE DESCRIPTION", "private-source", "private-registry.json"} {
if strings.Contains(message, forbidden) {
t.Fatalf("identity error leaked %q: %s", forbidden, message)
}
}
}
func TestRegistryAccessorsAndLookupAreDefensive(t *testing.T) {
resolved, err := Resolve(registryReference(encodeRegistry(t, validRegistryList()), "file:///npc-registry.json"))
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v, want nil", err) t.Fatal(err)
}
if !bytes.Equal(baseBytes, changedBytes) || semanticDigest(baseBytes) != semanticDigest(changedBytes) {
t.Fatalf("equivalent name projections differ: %s / %s", baseBytes, changedBytes)
} }
npcs := resolved.NPCs() nameChanged := registryFixture()
nameChanged.NPCs[0].Name = "The Greencloak"
nameBytes, _ := nameProjection(nameChanged)
orderChanged := registryFixture()
orderChanged.NPCs[0], orderChanged.NPCs[1] = orderChanged.NPCs[1], orderChanged.NPCs[0]
orderBytes, _ := nameProjection(orderChanged)
if bytes.Equal(baseBytes, nameBytes) || bytes.Equal(baseBytes, orderBytes) {
t.Fatalf("name/order changes did not change projection: %s %s %s", baseBytes, nameBytes, orderBytes)
}
}
func TestRegistryLookupAndAccessorsAreImmutable(t *testing.T) {
registry := resolveList(t, registryFixture())
if npc, ok := registry.Lookup(" mIRA\u2003thorn "); !ok || npc.Name != "Mira Thorn" {
t.Fatalf("Lookup() = %#v, %t", npc, ok)
}
if _, ok := registry.Lookup("The Greencloak"); ok {
t.Fatal("Lookup() accepted a non-canonical name")
}
npcs := registry.NPCs()
npcs[0].Name = "changed" npcs[0].Name = "changed"
npcs[0].Aliases[0] = "changed alias" npcs[0].SourceRefs[0].SourceID = "changed"
npcs[0].Relationships[0].Target = "changed target" content := registry.CanonicalBytes()
npcs[0].SourceRefs[0].SourceID = "changed source" content[0] = '['
if got, ok := resolved.Lookup("The Greencloak"); !ok || got.Name != "Mira Thorn" { input := registry.PromptInput()
t.Fatalf("Lookup() after NPC mutation = %#v, %v, want original NPC", got, ok) input.Content[0] = '['
if next := registry.NPCs()[0]; next.Name != "Mira Thorn" || next.SourceRefs[0].SourceID != "session-alpha" {
t.Fatalf("registry mutated through accessor: %#v", next)
} }
if registry.CanonicalBytes()[0] != '{' || registry.PromptInput().Content[0] != '{' {
wantCanonical := string(resolved.CanonicalBytes()) t.Fatal("registry bytes mutated through accessor")
content := resolved.CanonicalBytes()
content[0] = 'X'
input := resolved.PromptInput()
input.Content[0] = 'X'
if string(resolved.CanonicalBytes()) != wantCanonical || string(resolved.PromptInput().Content) != wantCanonical {
t.Fatal("registry content accessors share mutable state")
}
if got, ok := resolved.Lookup(" MIRA\u00a0THORN "); !ok || got.Name != "Mira Thorn" {
t.Fatalf("Lookup() canonical identity = %#v, %v, want Mira Thorn", got, ok)
}
if _, ok := resolved.Lookup("unknown NPC"); ok {
t.Fatal("Lookup() found unknown NPC")
} }
} }
func TestResolverUsesSeededFallbackAndCachesGeneratedCanonicalRegistry(t *testing.T) { func TestResolveRejectsMalformedOrUnsupportedRegistryInput(t *testing.T) {
staticContent := encodeRegistry(t, validRegistryList()) for _, item := range []contracts.ReferenceItem{
resolver, err := NewResolver(registryReference(staticContent, "file:///static.json")) {MediaType: "application/json", Content: []byte(`{"npcs":[`)},
{MediaType: "text/plain", Content: []byte(`{"npcs":[]}`)},
} {
_, err := Resolve(referenceSet(item))
if err == nil {
t.Fatalf("Resolve(%s) error = nil", item.Content)
}
}
}
func TestResolverReusesEquivalentCanonicalRegistries(t *testing.T) {
set := listReferenceSet(t, registryFixture())
resolver, err := NewResolver(set)
if err != nil { if err != nil {
t.Fatalf("NewResolver() error = %v", err) t.Fatal(err)
} }
if got, err := resolver.Resolve(contracts.ReferenceSet{}); err != nil || got != resolver.Seeded() { resolved, err := resolver.Resolve(set)
t.Fatalf("Resolve(absent) = %p, %v, want seeded %p", got, err, resolver.Seeded()) if err != nil || resolved != resolver.Seeded() {
} t.Fatalf("Resolve() = %p, %v; seeded %p", resolved, err, resolver.Seeded())
generated := registryReference(append([]byte("\n"), staticContent...), "file:///generated.json")
first, err := resolver.Resolve(generated)
if err != nil {
t.Fatalf("Resolve(generated) error = %v", err)
}
second, err := resolver.Resolve(generated)
if err != nil {
t.Fatalf("Resolve(generated second) error = %v", err)
}
if first != resolver.Seeded() || second != first {
t.Fatalf("resolved registries = %p, %p, seeded %p; want seeded reuse", first, second, resolver.Seeded())
}
changed := validRegistryList()
changed.NPCs[0].Description = "A changed generated description."
changedContent := encodeRegistry(t, changed)
changedReferences := registryReference(changedContent, "file:///changed.json")
resolved, err := resolver.Resolve(changedReferences)
if err != nil {
t.Fatalf("Resolve(changed) error = %v", err)
}
changedReferences.Slots[ReferenceSlot].Items[0].Content[0] = 'X'
if resolved == first || string(resolved.CanonicalBytes()) != string(changedContent) {
t.Fatalf("changed registry = %p/%s, want independent canonical cache entry", resolved, resolved.CanonicalBytes())
} }
} }
func TestResolverSharesOneCachedRegistryAcrossConcurrentOperations(t *testing.T) { func registryFixture() dnd.NPCList {
content := encodeRegistry(t, validRegistryList()) return dnd.NPCList{NPCs: []dnd.NPC{
resolver, err := NewResolver(contracts.ReferenceSet{}) {ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}},
if err != nil { {ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
t.Fatalf("NewResolver() error = %v", err)
}
references := registryReference(content, "file:///generated.json")
const callers = 32
results := make(chan *Registry, callers)
errors := make(chan error, callers)
var wait sync.WaitGroup
for index := 0; index < callers; index++ {
wait.Add(1)
go func() {
defer wait.Done()
resolved, resolveErr := resolver.Resolve(references)
if resolveErr != nil {
errors <- resolveErr
return
}
results <- resolved
}()
}
wait.Wait()
close(results)
close(errors)
for err := range errors {
t.Fatalf("concurrent Resolve() error = %v", err)
}
var first *Registry
for resolved := range results {
if first == nil {
first = resolved
} else if resolved != first {
t.Fatalf("concurrent resolved registry %p differs from cached %p", resolved, first)
}
}
}
func TestNewResolverAllowsGeneratedDeclarationButRejectsMalformedStaticItem(t *testing.T) {
placeholder := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
ReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ReferenceSlot, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}}},
}}
if _, err := NewResolver(placeholder); err != nil {
t.Fatalf("NewResolver(generated declaration) error = %v, want nil", err)
}
malformed := registryReference([]byte(`{"npcs":[`), "file:///runtime.json")
if _, err := NewResolver(malformed); err == nil || !strings.Contains(err.Error(), "invalid approved NPC JSON") {
t.Fatalf("NewResolver(malformed) error = %v, want bounded decode failure", err)
}
}
func validRegistryList() dnd.NPCList {
return dnd.NPCList{NPCs: []dnd.NPC{{
ID: identity.DeriveID("Mira Thorn"),
Name: "Mira Thorn",
Aliases: []string{"The Greencloak"},
Description: "A guarded ranger who watches the northern road.",
Relationships: []dnd.NPCRelationship{{
Target: "Captain Vale", Relationship: "reports to",
}},
SourceRefs: []source.SourceRef{{SourceID: "npc-session", StartUnitID: 41, EndUnitID: 43}},
}}}
}
func encodeRegistry(t *testing.T, value dnd.NPCList) []byte {
t.Helper()
content, err := npccodec.New().Encode(value)
if err != nil {
t.Fatalf("encode NPC registry: %v", err)
}
return content
}
func marshalRegistry(t *testing.T, value dnd.NPCList) []byte {
t.Helper()
content, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal NPC registry: %v", err)
}
return content
}
func registryReference(content []byte, origin string) contracts.ReferenceSet {
references := registryReferenceWithMedia(content, "application/json; charset=utf-8")
item := references.Slots[ReferenceSlot].Items[0]
item.Origin.URI = origin
slot := references.Slots[ReferenceSlot]
slot.Items[0] = item
references.Slots[ReferenceSlot] = slot
return references
}
func registryReferenceWithMedia(content []byte, mediaType string) contracts.ReferenceSet {
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
ReferenceSlot: {
Slot: contracts.ReferenceSlot{Name: ReferenceSlot, AcceptedMediaTypes: []string{npccodec.MediaType}, MaxBytes: MaxBytes},
Items: []contracts.ReferenceItem{{
SlotName: ReferenceSlot,
MediaType: mediaType,
Content: append([]byte(nil), content...),
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///npc-registry.json"},
}},
},
}} }}
} }
func resolveList(t *testing.T, list dnd.NPCList) *Registry {
t.Helper()
registry, err := Resolve(listReferenceSet(t, list))
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
return registry
}
func listReferenceSet(t *testing.T, list dnd.NPCList) contracts.ReferenceSet {
t.Helper()
content, err := npccodec.New().Encode(list)
if err != nil {
t.Fatalf("Encode() error = %v", err)
}
return referenceSet(contracts.ReferenceItem{SlotName: ReferenceSlot, MediaType: npccodec.MediaType, Content: content})
}
func referenceSet(item contracts.ReferenceItem) contracts.ReferenceSet {
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {Items: []contracts.ReferenceItem{item}}}}
}
func TestProjectionIsStableForEquivalentNormalizedRegistries(t *testing.T) {
first := resolveList(t, registryFixture())
secondList := registryFixture()
secondList.NPCs[0].SourceRefs = append(secondList.NPCs[0].SourceRefs, source.SourceRef{SourceID: "session-beta", StartUnitID: 8, EndUnitID: 8})
second := resolveList(t, secondList)
if !reflect.DeepEqual(first.PromptInput().Content, second.PromptInput().Content) || first.ProjectionDigest() != second.ProjectionDigest() || first.Digest() == second.Digest() {
t.Fatalf("projection/full identity mismatch: %#v %#v", first, second)
}
}

View File

@@ -32,10 +32,10 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
Module: spellextract.Key, Module: spellextract.Key,
Validators: []pipeline.ModuleBinding{ Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key), pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(spellshape.Key), pipeline.Binding(spellshape.Key),
pipeline.Binding(spellcatalog.Key), pipeline.Binding(spellcatalog.Key),
pipeline.Binding(spellsourcerefs.Key), pipeline.Binding(spellsourcerefs.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(spellrelatedness.Key), pipeline.Binding(spellrelatedness.Key),
}, },
}) })
@@ -46,10 +46,10 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
Module: spellnormalize.Key, Module: spellnormalize.Key,
Validators: []pipeline.ModuleBinding{ Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key), pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(spellshape.Key), pipeline.Binding(spellshape.Key),
pipeline.Binding(spellcatalog.Key), pipeline.Binding(spellcatalog.Key),
pipeline.Binding(spellsourcerefs.Key), pipeline.Binding(spellsourcerefs.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(spellrelatedness.Key), pipeline.Binding(spellrelatedness.Key),
}, },
}) })
@@ -60,9 +60,9 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
Module: npcextract.Key, Module: npcextract.Key,
Validators: []pipeline.ModuleBinding{ Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key), pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(npcshape.Key), pipeline.Binding(npcshape.Key),
pipeline.Binding(npcsourcerefs.Key), pipeline.Binding(npcsourcerefs.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(npcrelatedness.Key), pipeline.Binding(npcrelatedness.Key),
}, },
}) })
@@ -73,10 +73,10 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
Module: npcnormalize.Key, Module: npcnormalize.Key,
Validators: []pipeline.ModuleBinding{ Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key), pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(npcshape.Key), pipeline.Binding(npcshape.Key),
pipeline.Binding(npcidentity.Key), pipeline.Binding(npcidentity.Key),
pipeline.Binding(npcsourcerefs.Key), pipeline.Binding(npcsourcerefs.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(npcrelatedness.Key), pipeline.Binding(npcrelatedness.Key),
}, },
}) })
@@ -87,9 +87,9 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
Module: combatextract.Key, Module: combatextract.Key,
Validators: []pipeline.ModuleBinding{ Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key), pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(combatshape.Key), pipeline.Binding(combatshape.Key),
pipeline.Binding(combatsourcerefs.Key), pipeline.Binding(combatsourcerefs.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(combatrelatedness.Key), pipeline.Binding(combatrelatedness.Key),
}, },
}) })
@@ -100,10 +100,10 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
Module: combatnormalize.Key, Module: combatnormalize.Key,
Validators: []pipeline.ModuleBinding{ Validators: []pipeline.ModuleBinding{
pipeline.Binding(validjson.Key), pipeline.Binding(validjson.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(combatshape.Key), pipeline.Binding(combatshape.Key),
pipeline.Binding(combatinvariants.Key), pipeline.Binding(combatinvariants.Key),
pipeline.Binding(combatsourcerefs.Key), pipeline.Binding(combatsourcerefs.Key),
pipeline.Binding(validjsonschema.Key),
pipeline.Binding(combatrelatedness.Key), pipeline.Binding(combatrelatedness.Key),
}, },
}) })

View File

@@ -59,23 +59,6 @@ func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, err
func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn { func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
clone := value clone := value
if value.Round != nil {
round := *value.Round
clone.Round = &round
}
if value.Actions != nil {
clone.Actions = make([]dnd.CombatAction, len(value.Actions))
for index, action := range value.Actions {
clone.Actions[index] = action
if action.Targets != nil {
clone.Actions[index].Targets = append([]string(nil), action.Targets...)
}
if action.Resolution != nil {
resolution := *action.Resolution
clone.Actions[index].Resolution = &resolution
}
}
}
if value.SourceRefs != nil { if value.SourceRefs != nil {
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...) clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
} }

View File

@@ -52,10 +52,10 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
}) })
wantChain := []pipeline.ModuleBinding{ wantChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/spells/shape"), pipeline.Binding("extract/dnd/spells/shape"),
pipeline.Binding("extract/dnd/spells/catalog"), pipeline.Binding("extract/dnd/spells/catalog"),
pipeline.Binding("extract/dnd/spells/source_refs"), pipeline.Binding("extract/dnd/spells/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/spells/source_relatedness"), pipeline.Binding("extract/dnd/spells/source_relatedness"),
} }
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) { if got := registries.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
@@ -66,9 +66,9 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
} }
npcExtractChain := []pipeline.ModuleBinding{ npcExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/npcs/shape"), pipeline.Binding("extract/dnd/npcs/shape"),
pipeline.Binding("extract/dnd/npcs/source_refs"), pipeline.Binding("extract/dnd/npcs/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/npcs/source_relatedness"), pipeline.Binding("extract/dnd/npcs/source_relatedness"),
} }
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, npcextract.Key); !reflect.DeepEqual(got, npcExtractChain) { if got := registries.ValidatorChains.Validators(pipeline.StageExtract, npcextract.Key); !reflect.DeepEqual(got, npcExtractChain) {
@@ -76,10 +76,10 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
} }
npcNormalizeChain := []pipeline.ModuleBinding{ npcNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/npcs/shape"), pipeline.Binding("extract/dnd/npcs/shape"),
pipeline.Binding("normalize/dnd/npcs/identity"), pipeline.Binding("normalize/dnd/npcs/identity"),
pipeline.Binding("extract/dnd/npcs/source_refs"), pipeline.Binding("extract/dnd/npcs/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/npcs/source_relatedness"), pipeline.Binding("extract/dnd/npcs/source_relatedness"),
} }
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, npcnormalize.Key); !reflect.DeepEqual(got, npcNormalizeChain) { if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, npcnormalize.Key); !reflect.DeepEqual(got, npcNormalizeChain) {
@@ -87,9 +87,9 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
} }
combatExtractChain := []pipeline.ModuleBinding{ combatExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/shape"), pipeline.Binding("extract/dnd/combat-turns/shape"),
pipeline.Binding("extract/dnd/combat-turns/source_refs"), pipeline.Binding("extract/dnd/combat-turns/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"), pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
} }
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, combatExtractChain) { if got := registries.ValidatorChains.Validators(pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, combatExtractChain) {
@@ -97,10 +97,10 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
} }
combatNormalizeChain := []pipeline.ModuleBinding{ combatNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/shape"), pipeline.Binding("extract/dnd/combat-turns/shape"),
pipeline.Binding("normalize/dnd/combat-turns/invariants"), pipeline.Binding("normalize/dnd/combat-turns/invariants"),
pipeline.Binding("extract/dnd/combat-turns/source_refs"), pipeline.Binding("extract/dnd/combat-turns/source_refs"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"), pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
} }
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) { if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) {
@@ -186,12 +186,9 @@ func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) {
} }
func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) { func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) {
round := 1
resolution := "hit"
targets := []string{"Mira"}
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
input := []dnd.CombatTurnList{ input := []dnd.CombatTurnList{
{CombatTurns: []dnd.CombatTurn{{Actor: "first", Round: &round, Actions: []dnd.CombatAction{{Targets: targets, Resolution: &resolution}}, SourceRefs: refs}}}, {CombatTurns: []dnd.CombatTurn{{Actor: "first", SourceRefs: refs}}},
{CombatTurns: []dnd.CombatTurn{{Actor: "second"}}}, {CombatTurns: []dnd.CombatTurn{{Actor: "second"}}},
} }
got, err := appendCombatTurnLists(input) got, err := appendCombatTurnLists(input)
@@ -201,7 +198,7 @@ func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) {
if len(got.CombatTurns) != 2 || got.CombatTurns[0].Actor != "first" || got.CombatTurns[1].Actor != "second" { if len(got.CombatTurns) != 2 || got.CombatTurns[0].Actor != "first" || got.CombatTurns[1].Actor != "second" {
t.Fatalf("combat turns = %#v, want chunk order", got.CombatTurns) t.Fatalf("combat turns = %#v, want chunk order", got.CombatTurns)
} }
if got.CombatTurns[0].Round == &round || &got.CombatTurns[0].Actions[0].Targets[0] == &targets[0] || got.CombatTurns[0].Actions[0].Resolution == &resolution || &got.CombatTurns[0].SourceRefs[0] == &refs[0] { if &got.CombatTurns[0].SourceRefs[0] == &refs[0] {
t.Fatal("appendCombatTurnLists() retained nested input aliases") t.Fatal("appendCombatTurnLists() retained nested input aliases")
} }
tests := []struct { tests := []struct {

View File

@@ -28,7 +28,6 @@ var sharedPromptPaths = map[string]string{
"common-dnd-identity.md": "assets/prompts/common-dnd-identity.md", "common-dnd-identity.md": "assets/prompts/common-dnd-identity.md",
"common-dnd-transcript.md": "assets/prompts/common-dnd-transcript.md", "common-dnd-transcript.md": "assets/prompts/common-dnd-transcript.md",
"common-dnd-references.md": "assets/prompts/common-dnd-references.md", "common-dnd-references.md": "assets/prompts/common-dnd-references.md",
"common-dnd-immediate-resolution.md": "assets/prompts/common-dnd-immediate-resolution.md",
"common-dnd-npcs.md": "assets/prompts/common-dnd-npcs.md", "common-dnd-npcs.md": "assets/prompts/common-dnd-npcs.md",
} }

View File

@@ -1,7 +0,0 @@
Report only a declaration or action and its immediate observed resolution.
Immediate resolution may include directly associated rolls, damage, healing,
movement, conditions, target outcomes, interruptions, or other outcomes shown
with that declaration or action.
Do not follow consequences that occur on later turns or elsewhere in the
scene.

View File

@@ -19,8 +19,6 @@ type SpellList struct {
type SpellCast struct { type SpellCast struct {
Caster string `json:"caster"` Caster string `json:"caster"`
Spell string `json:"spell"` Spell string `json:"spell"`
Effect string `json:"effect"`
NarrativeDescription string `json:"narrative_description"`
SourceRefs []source.SourceRef `json:"source_refs"` SourceRefs []source.SourceRef `json:"source_refs"`
} }
@@ -31,17 +29,9 @@ type NPCList struct {
type NPC struct { type NPC struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Aliases []string `json:"aliases"`
Description string `json:"description"`
Relationships []NPCRelationship `json:"relationships"`
SourceRefs []source.SourceRef `json:"source_refs"` SourceRefs []source.SourceRef `json:"source_refs"`
} }
type NPCRelationship struct {
Target string `json:"target"`
Relationship string `json:"relationship"`
}
type CombatTurnList struct { type CombatTurnList struct {
CombatTurns []CombatTurn `json:"combat_turns"` CombatTurns []CombatTurn `json:"combat_turns"`
} }
@@ -59,28 +49,5 @@ const (
type CombatTurn struct { type CombatTurn struct {
Actor string `json:"actor"` Actor string `json:"actor"`
TurnKind CombatTurnKind `json:"turn_kind"` TurnKind CombatTurnKind `json:"turn_kind"`
Round *int `json:"round"`
Actions []CombatAction `json:"actions"`
Summary string `json:"summary"`
SourceRefs []source.SourceRef `json:"source_refs"` SourceRefs []source.SourceRef `json:"source_refs"`
} }
type CombatActionCategory string
const (
CombatActionCategoryAttack CombatActionCategory = "attack"
CombatActionCategorySpell CombatActionCategory = "spell"
CombatActionCategoryMovement CombatActionCategory = "movement"
CombatActionCategoryItem CombatActionCategory = "item"
CombatActionCategoryAbilityCheck CombatActionCategory = "ability_check"
CombatActionCategorySavingThrow CombatActionCategory = "saving_throw"
CombatActionCategoryCondition CombatActionCategory = "condition"
CombatActionCategoryOther CombatActionCategory = "other"
)
type CombatAction struct {
Category CombatActionCategory `json:"category"`
Declaration string `json:"declaration"`
Targets []string `json:"targets"`
Resolution *string `json:"resolution"`
}

View File

@@ -66,31 +66,6 @@ func issuesFor(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
if turn.Actor != identity.NormalizeDisplay(turn.Actor) { if turn.Actor != identity.NormalizeDisplay(turn.Actor) {
issues = append(issues, prefix+".actor is not display-normalized: "+diagnostics.Quote(turn.Actor)) issues = append(issues, prefix+".actor is not display-normalized: "+diagnostics.Quote(turn.Actor))
} }
if turn.Summary != identity.NormalizeDisplay(turn.Summary) {
issues = append(issues, prefix+".summary is not display-normalized: "+diagnostics.Quote(turn.Summary))
}
for actionIndex, action := range turn.Actions {
actionPrefix := fmt.Sprintf("%s.actions[%d]", prefix, actionIndex)
if action.Declaration != identity.NormalizeDisplay(action.Declaration) {
issues = append(issues, actionPrefix+".declaration is not display-normalized: "+diagnostics.Quote(action.Declaration))
}
seenTargets := make(map[string]int, len(action.Targets))
for targetIndex, target := range action.Targets {
if target != identity.NormalizeDisplay(target) {
issues = append(issues, fmt.Sprintf("%s.targets[%d] is not display-normalized: %s", actionPrefix, targetIndex, diagnostics.Quote(target)))
}
key := identity.ComparisonKey(target)
if previous, exists := seenTargets[key]; exists {
issues = append(issues, fmt.Sprintf("%s.targets[%d] duplicates target %d under comparison identity", actionPrefix, targetIndex, previous))
} else {
seenTargets[key] = targetIndex
}
}
if action.Resolution != nil && *action.Resolution != identity.NormalizeDisplay(*action.Resolution) {
issues = append(issues, actionPrefix+".resolution is not display-normalized: "+diagnostics.Quote(*action.Resolution))
}
}
for refIndex := 1; refIndex < len(turn.SourceRefs); refIndex++ { for refIndex := 1; refIndex < len(turn.SourceRefs); refIndex++ {
previous := turn.SourceRefs[refIndex-1] previous := turn.SourceRefs[refIndex-1]
current := turn.SourceRefs[refIndex] current := turn.SourceRefs[refIndex]
@@ -169,12 +144,6 @@ func duplicateKey(turn dnd.CombatTurn) (string, bool) {
var key strings.Builder var key strings.Builder
writeKeyString(&key, identity.ComparisonKey(turn.Actor)) writeKeyString(&key, identity.ComparisonKey(turn.Actor))
writeKeyString(&key, string(turn.TurnKind)) writeKeyString(&key, string(turn.TurnKind))
if turn.Round == nil {
key.WriteByte('0')
} else {
key.WriteByte('1')
writeKeyInt(&key, *turn.Round)
}
for _, ref := range turn.SourceRefs { for _, ref := range turn.SourceRefs {
writeKeyString(&key, ref.SourceID) writeKeyString(&key, ref.SourceID)
writeKeyInt(&key, ref.StartUnitID) writeKeyInt(&key, ref.StartUnitID)

View File

@@ -27,22 +27,6 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
want string want string
}{ }{
{name: "actor display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { value.CombatTurns[0].Actor = " Aria " }, want: "actor is not display-normalized"}, {name: "actor display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { value.CombatTurns[0].Actor = " Aria " }, want: "actor is not display-normalized"},
{name: "summary display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
value.CombatTurns[0].Summary = "Aria attacks"
}, want: "summary is not display-normalized"},
{name: "declaration display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
value.CombatTurns[0].Actions[0].Declaration = "Aria attacks"
}, want: "declaration is not display-normalized"},
{name: "target display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
value.CombatTurns[0].Actions[0].Targets = []string{" Goblin"}
}, want: "targets[0] is not display-normalized"},
{name: "duplicate target identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
value.CombatTurns[0].Actions[0].Targets = []string{"Goblin", " goblin"}
}, want: "duplicates target"},
{name: "resolution display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
resolution := " hit "
value.CombatTurns[0].Actions[0].Resolution = &resolution
}, want: "resolution is not display-normalized"},
{name: "reference order", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { {name: "reference order", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
value.CombatTurns[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}} value.CombatTurns[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
}, want: "not in canonical order"}, }, want: "not in canonical order"},
@@ -53,9 +37,7 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
value.CombatTurns = []dnd.CombatTurn{withRef(value.CombatTurns[0], source.SourceRef{SourceID: "session", StartUnitID: 20, EndUnitID: 20}), value.CombatTurns[0]} value.CombatTurns = []dnd.CombatTurn{withRef(value.CombatTurns[0], source.SourceRef{SourceID: "session", StartUnitID: 20, EndUnitID: 20}), value.CombatTurns[0]}
}, want: "out of chronological order"}, }, want: "out of chronological order"},
{name: "duplicate identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { {name: "duplicate identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
duplicate := value.CombatTurns[0] value.CombatTurns = append(value.CombatTurns, value.CombatTurns[0])
duplicate.Summary = "different prose"
value.CombatTurns = append(value.CombatTurns, duplicate)
}, want: "duplicates combat turn"}, }, want: "duplicates combat turn"},
} }
for _, test := range tests { for _, test := range tests {
@@ -121,11 +103,9 @@ func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
} }
func normalizedList() dnd.CombatTurnList { func normalizedList() dnd.CombatTurnList {
resolution := "hit"
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Round: intPointer(1), Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{"Goblin"}, Resolution: &resolution}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}},
Summary: "Aria attacks", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}},
}}} }}}
} }
@@ -133,8 +113,6 @@ func invariantDocument() *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}} return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}}
} }
func intPointer(value int) *int { return &value }
func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn { func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn {
turn.SourceRefs = []source.SourceRef{ref} turn.SourceRefs = []source.SourceRef{ref}
return turn return turn

View File

@@ -60,39 +60,9 @@ func issuesFor(value dnd.CombatTurnList) []string {
if !validTurnKind(turn.TurnKind) { if !validTurnKind(turn.TurnKind) {
issues = append(issues, prefix+".turn_kind is unsupported: "+diagnostics.Quote(string(turn.TurnKind))) issues = append(issues, prefix+".turn_kind is unsupported: "+diagnostics.Quote(string(turn.TurnKind)))
} }
if turn.Round != nil && *turn.Round <= 0 {
issues = append(issues, prefix+".round must be positive or null")
}
if len(turn.Actions) == 0 {
issues = append(issues, prefix+".actions must contain at least one action")
}
if strings.TrimSpace(turn.Summary) == "" {
issues = append(issues, prefix+".summary must not be empty: "+diagnostics.Quote(turn.Summary))
}
if len(turn.SourceRefs) == 0 { if len(turn.SourceRefs) == 0 {
issues = append(issues, prefix+".source_refs must contain at least one reference") issues = append(issues, prefix+".source_refs must contain at least one reference")
} }
for actionIndex, action := range turn.Actions {
actionPrefix := fmt.Sprintf("%s.actions[%d]", prefix, actionIndex)
if !validActionCategory(action.Category) {
issues = append(issues, actionPrefix+".category is unsupported: "+diagnostics.Quote(string(action.Category)))
}
if strings.TrimSpace(action.Declaration) == "" {
issues = append(issues, actionPrefix+".declaration must not be empty: "+diagnostics.Quote(action.Declaration))
}
if action.Targets == nil {
issues = append(issues, actionPrefix+".targets must be present")
} else {
for targetIndex, target := range action.Targets {
if strings.TrimSpace(target) == "" {
issues = append(issues, fmt.Sprintf("%s.targets[%d] must not be empty: %s", actionPrefix, targetIndex, diagnostics.Quote(target)))
}
}
}
if action.Resolution != nil && strings.TrimSpace(*action.Resolution) == "" {
issues = append(issues, actionPrefix+".resolution must not be empty or null: "+diagnostics.Quote(*action.Resolution))
}
}
} }
return issues return issues
} }
@@ -106,15 +76,6 @@ func validTurnKind(value dnd.CombatTurnKind) bool {
} }
} }
func validActionCategory(value dnd.CombatActionCategory) bool {
switch value {
case dnd.CombatActionCategoryAttack, dnd.CombatActionCategorySpell, dnd.CombatActionCategoryMovement, dnd.CombatActionCategoryItem, dnd.CombatActionCategoryAbilityCheck, dnd.CombatActionCategorySavingThrow, dnd.CombatActionCategoryCondition, dnd.CombatActionCategoryOther:
return true
default:
return false
}
}
func Spec() pipeline.ValidatorSpec { func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
} }

View File

@@ -28,18 +28,7 @@ func TestValidateRejectsEveryOwnedShapeBoundary(t *testing.T) {
{name: "missing combat turns", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns = nil }, want: "combat_turns must be present"}, {name: "missing combat turns", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns = nil }, want: "combat_turns must be present"},
{name: "empty actor", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actor = " " }, want: "actor must not be empty"}, {name: "empty actor", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actor = " " }, want: "actor must not be empty"},
{name: "unsupported turn kind", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].TurnKind = "unknown" }, want: "turn_kind is unsupported"}, {name: "unsupported turn kind", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].TurnKind = "unknown" }, want: "turn_kind is unsupported"},
{name: "non-positive round", mutate: func(value *dnd.CombatTurnList) { round := 0; value.CombatTurns[0].Round = &round }, want: "round must be positive"},
{name: "missing actions", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = nil }, want: "actions must contain"},
{name: "empty summary", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Summary = " " }, want: "summary must not be empty"},
{name: "missing source refs", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs = nil }, want: "source_refs must contain"}, {name: "missing source refs", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].SourceRefs = nil }, want: "source_refs must contain"},
{name: "unsupported category", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Category = "unknown" }, want: "category is unsupported"},
{name: "empty declaration", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Declaration = " " }, want: "declaration must not be empty"},
{name: "missing targets", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = nil }, want: "targets must be present"},
{name: "empty target", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions[0].Targets = []string{" "} }, want: "targets[0] must not be empty"},
{name: "empty resolution", mutate: func(value *dnd.CombatTurnList) {
resolution := " "
value.CombatTurns[0].Actions[0].Resolution = &resolution
}, want: "resolution must not be empty"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -85,11 +74,8 @@ func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
} }
func validCombatTurnList() dnd.CombatTurnList { func validCombatTurnList() dnd.CombatTurnList {
round := 2
resolution := "The goblin is wounded."
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Round: &round, Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{}, Resolution: &resolution}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}} }}}
} }

View File

@@ -87,11 +87,9 @@ func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
} }
func validCombatTurnList() dnd.CombatTurnList { func validCombatTurnList() dnd.CombatTurnList {
resolution := "The goblin is wounded."
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{}, Resolution: &resolution}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}} }}}
} }

View File

@@ -3,7 +3,6 @@ package sourcerelatedness
import ( import (
"context" "context"
"fmt" "fmt"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
@@ -50,22 +49,15 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
warnings := make([]contracts.Warning, 0) warnings := make([]contracts.Warning, 0)
for turnIndex, turn := range req.Value.CombatTurns { for turnIndex, turn := range req.Value.CombatTurns {
citedText := citedTexts[turnIndex] citedText := citedTexts[turnIndex]
issues := make([]string, 0) if actorAppearsInCitedText(citedText, turn.Actor) {
if !actorAppearsInCitedText(citedText, turn.Actor) {
issues = append(issues, fmt.Sprintf("actor %s was not found in cited source text", diagnostics.Quote(turn.Actor)))
}
for actionIndex, action := range turn.Actions {
if !declarationAppearsInCitedText(citedText, action.Declaration) {
issues = append(issues, fmt.Sprintf("action %d declaration %s was not found in cited source text", actionIndex, diagnostics.Quote(action.Declaration)))
}
}
if len(issues) == 0 {
continue continue
} }
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: fmt.Sprintf("combat_turns[%d]", turnIndex), Scope: fmt.Sprintf("combat_turns[%d]", turnIndex),
ReasonCode: WarningReasonCode, ReasonCode: WarningReasonCode,
Message: diagnostics.Aggregate("combat turn not near source", issues), Message: diagnostics.Aggregate("combat turn not near source", []string{
fmt.Sprintf("actor %s was not found in cited source text", diagnostics.Quote(turn.Actor)),
}),
}) })
} }
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
@@ -74,27 +66,6 @@ func actorAppearsInCitedText(citedText string, actor string) bool {
return shared.ContainsTokenSequence(citedText, actor) return shared.ContainsTokenSequence(citedText, actor)
} }
func declarationAppearsInCitedText(citedText string, declaration string) bool {
citedTokens := tokenSet(citedText)
for _, token := range shared.NormalizedTokens(declaration) {
if utf8.RuneCountInString(token) >= 4 {
if _, ok := citedTokens[token]; ok {
return true
}
}
}
return false
}
func tokenSet(value string) map[string]struct{} {
tokens := shared.NormalizedTokens(value)
set := make(map[string]struct{}, len(tokens))
for _, token := range tokens {
set[token] = struct{}{}
}
return set
}
func Spec() pipeline.ValidatorSpec { func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
} }

View File

@@ -12,12 +12,10 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
) )
func TestValidatorUsesDocumentOrderAndUnicodeComparisonForActorAndDeclaration(t *testing.T) { func TestValidatorUsesDocumentOrderAndUnicodeComparisonForActor(t *testing.T) {
resolution := "The goblin is hit."
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "O'Rin Thorn", TurnKind: dnd.CombatTurnKindTurn, Actor: "O'Rin Thorn", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "O'Rin attacks", Targets: []string{"unmentioned target"}, Resolution: &resolution}}, SourceRefs: []source.SourceRef{
Summary: "O'Rin attacks.", SourceRefs: []source.SourceRef{
{SourceID: "session", StartUnitID: 2, EndUnitID: 2}, {SourceID: "session", StartUnitID: 2, EndUnitID: 2},
{SourceID: "session", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session", StartUnitID: 1, EndUnitID: 2},
}, },
@@ -32,31 +30,25 @@ func TestValidatorUsesDocumentOrderAndUnicodeComparisonForActorAndDeclaration(t
} }
} }
func TestValidatorWarnsOncePerTurnForUnrelatedActorAndActions(t *testing.T) { func TestValidatorWarnsOncePerTurnForUnrelatedActor(t *testing.T) {
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Missing\nName", TurnKind: dnd.CombatTurnKindReaction, Actor: "Missing\nName", TurnKind: dnd.CombatTurnKindReaction,
Actions: []dnd.CombatAction{ SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
{Category: dnd.CombatActionCategoryOther, Declaration: "hit", Targets: []string{}, Resolution: nil},
{Category: dnd.CombatActionCategoryOther, Declaration: "unseen monster", Targets: []string{}, Resolution: nil},
},
Summary: "An unrelated event.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}} }}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: value}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 1 { if err != nil || !result.Approved || len(result.Warnings) != 1 {
t.Fatalf("Validate() = %#v, %v; want one warning for the turn", result, err) t.Fatalf("Validate() = %#v, %v; want one warning for the turn", result, err)
} }
warning := result.Warnings[0] warning := result.Warnings[0]
if warning.Scope != "combat_turns[0]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, `Missing\nName`) || strings.Contains(warning.Message, "Missing\nName") || !strings.Contains(warning.Message, "action 0") || !strings.Contains(warning.Message, "action 1") || !utf8.ValidString(warning.Message) { if warning.Scope != "combat_turns[0]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, `Missing\nName`) || strings.Contains(warning.Message, "Missing\nName") || !utf8.ValidString(warning.Message) {
t.Fatalf("warning = %#v, want one safely quoted bounded warning", warning) t.Fatalf("warning = %#v, want one safely quoted bounded warning", warning)
} }
} }
func TestValidatorDoesNotMatchShortActorSubstring(t *testing.T) { func TestValidatorDoesNotMatchShortActorSubstring(t *testing.T) {
resolution := "The cart is struck."
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Art", TurnKind: dnd.CombatTurnKindTurn, Actor: "Art", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "cart attacks", Targets: []string{}, Resolution: &resolution}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
Summary: "The cart attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}} }}}
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The cart attacks."}}} doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The cart attacks."}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: value}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: value})
@@ -105,8 +97,7 @@ func TestValidatorIgnoresReferenceMaterialAndRegistersPolicy(t *testing.T) {
func validCombatTurnList() dnd.CombatTurnList { func validCombatTurnList() dnd.CombatTurnList {
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{"absent target"}, Resolution: nil}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}} }}}
} }

View File

@@ -48,9 +48,6 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
issues := make([]string, len(identityIssues)) issues := make([]string, len(identityIssues))
for index, issue := range identityIssues { for index, issue := range identityIssues {
location := fmt.Sprintf("npcs[%d]", issue.RecordIndex) location := fmt.Sprintf("npcs[%d]", issue.RecordIndex)
if issue.AliasIndex >= 0 {
location += fmt.Sprintf(".aliases[%d]", issue.AliasIndex)
}
issues[index] = fmt.Sprintf("%s %s: %s", location, issue.Code, diagnostics.Quote(issue.Value)) issues[index] = fmt.Sprintf("%s %s: %s", location, issue.Code, diagnostics.Quote(issue.Value))
} }
return contracts.ValidationResult{ return contracts.ValidationResult{

View File

@@ -43,7 +43,7 @@ func TestValidatorContractAndRegistration(t *testing.T) {
func TestValidatorDefersShapeAndRejectsIdentityIssues(t *testing.T) { func TestValidatorDefersShapeAndRejectsIdentityIssues(t *testing.T) {
validator := New(Options{}) validator := New(Options{})
shapeInvalid := dnd.NPCList{NPCs: []dnd.NPC{{Name: "missing description"}}} shapeInvalid := dnd.NPCList{NPCs: []dnd.NPC{{Name: "missing evidence"}}}
result, err := validator.Validate(context.Background(), validationRequest(shapeInvalid)) result, err := validator.Validate(context.Background(), validationRequest(shapeInvalid))
if err != nil || !result.Approved { if err != nil || !result.Approved {
t.Fatalf("shape-invalid result = %#v, error = %v, want deferred approval", result, err) t.Fatalf("shape-invalid result = %#v, error = %v, want deferred approval", result, err)
@@ -51,13 +51,13 @@ func TestValidatorDefersShapeAndRejectsIdentityIssues(t *testing.T) {
value := validNPCList(2) value := validNPCList(2)
value.NPCs[0].ID = "not-an-id" value.NPCs[0].ID = "not-an-id"
value.NPCs[1].Aliases = []string{"Shared Alias"} value.NPCs[1].Name = value.NPCs[0].Name
value.NPCs[0].Aliases = []string{"Shared Alias"} value.NPCs[1].ID = value.NPCs[0].ID
result, err = validator.Validate(context.Background(), validationRequest(value)) result, err = validator.Validate(context.Background(), validationRequest(value))
if err != nil || result.Approved || result.ReasonCode != ReasonCode { if err != nil || result.Approved || result.ReasonCode != ReasonCode {
t.Fatalf("identity result = %#v, error = %v, want rejection", result, err) t.Fatalf("identity result = %#v, error = %v, want rejection", result, err)
} }
for _, want := range []string{"invalid_id", "alias_owned_by_multiple_records", "npcs[0]", "npcs[1]"} { for _, want := range []string{"invalid_id", "duplicate_canonical_identity", "duplicate_id", "npcs[0]", "npcs[1]"} {
if !strings.Contains(result.Message, want) { if !strings.Contains(result.Message, want) {
t.Fatalf("identity message %q missing %q", result.Message, want) t.Fatalf("identity message %q missing %q", result.Message, want)
} }
@@ -90,14 +90,7 @@ func validNPCList(count int) dnd.NPCList {
value := dnd.NPCList{NPCs: make([]dnd.NPC, count)} value := dnd.NPCList{NPCs: make([]dnd.NPC, count)}
for index := range value.NPCs { for index := range value.NPCs {
name := fmt.Sprintf("NPC %d", index) name := fmt.Sprintf("NPC %d", index)
value.NPCs[index] = dnd.NPC{ value.NPCs[index] = dnd.NPC{ID: "npc:sha256:0000000000000000000000000000000000000000000000000000000000000000", Name: name, SourceRefs: []source.SourceRef{sourceRefForTest()}}
ID: "npc:sha256:0000000000000000000000000000000000000000000000000000000000000000",
Name: name,
Aliases: []string{},
Description: "description",
Relationships: []dnd.NPCRelationship{},
SourceRefs: []source.SourceRef{sourceRefForTest()},
}
} }
return value return value
} }

View File

@@ -61,31 +61,6 @@ func issuesFor(value dnd.NPCList) []string {
if strings.TrimSpace(npc.Name) == "" { if strings.TrimSpace(npc.Name) == "" {
issues = append(issues, prefix+".name must not be empty") issues = append(issues, prefix+".name must not be empty")
} }
if npc.Aliases == nil {
issues = append(issues, prefix+".aliases must be present")
} else {
for aliasIndex, alias := range npc.Aliases {
if strings.TrimSpace(alias) == "" {
issues = append(issues, fmt.Sprintf("%s.aliases[%d] must not be empty: %s", prefix, aliasIndex, diagnostics.Quote(alias)))
}
}
}
if strings.TrimSpace(npc.Description) == "" {
issues = append(issues, prefix+".description must not be empty")
}
if npc.Relationships == nil {
issues = append(issues, prefix+".relationships must be present")
} else {
for relationshipIndex, relationship := range npc.Relationships {
relationshipPrefix := fmt.Sprintf("%s.relationships[%d]", prefix, relationshipIndex)
if strings.TrimSpace(relationship.Target) == "" {
issues = append(issues, relationshipPrefix+".target must not be empty: "+diagnostics.Quote(relationship.Target))
}
if strings.TrimSpace(relationship.Relationship) == "" {
issues = append(issues, relationshipPrefix+".relationship must not be empty: "+diagnostics.Quote(relationship.Relationship))
}
}
}
if len(npc.SourceRefs) == 0 { if len(npc.SourceRefs) == 0 {
issues = append(issues, prefix+".source_refs must not be empty") issues = append(issues, prefix+".source_refs must not be empty")
} }

View File

@@ -21,9 +21,9 @@ func TestValidatorApprovesWellFormedNPCPayload(t *testing.T) {
func TestValidatorRejectsRequiredShapeValues(t *testing.T) { func TestValidatorRejectsRequiredShapeValues(t *testing.T) {
value := validNPCList() value := validNPCList()
value.NPCs[0].Aliases = nil value.NPCs[0].Name = ""
result, err := New(Options{}).Validate(context.Background(), requestWithValue(value)) result, err := New(Options{}).Validate(context.Background(), requestWithValue(value))
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "aliases must be present") { if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "name must not be empty") {
t.Fatalf("Validate() = %#v, %v; want bounded shape rejection", result, err) t.Fatalf("Validate() = %#v, %v; want bounded shape rejection", result, err)
} }
@@ -38,13 +38,13 @@ func TestValidatorBoundsDiagnosticsAndQuotesUnicode(t *testing.T) {
value := dnd.NPCList{NPCs: make([]dnd.NPC, 24)} value := dnd.NPCList{NPCs: make([]dnd.NPC, 24)}
long := strings.Repeat("火", 220) + "\n\t" long := strings.Repeat("火", 220) + "\n\t"
for index := range value.NPCs { for index := range value.NPCs {
value.NPCs[index] = dnd.NPC{ID: "candidate", Name: long, Aliases: []string{"\n\t"}, Description: "", Relationships: []dnd.NPCRelationship{{Target: " ", Relationship: " "}}, SourceRefs: []source.SourceRef{}} value.NPCs[index] = dnd.NPC{ID: "", Name: long, SourceRefs: []source.SourceRef{}}
} }
result, err := New(Options{}).Validate(context.Background(), requestWithValue(value)) result, err := New(Options{}).Validate(context.Background(), requestWithValue(value))
if err != nil || result.Approved || len([]byte(result.Message)) > diagnosticsMaxMessageBytes || !utf8.ValidString(result.Message) { if err != nil || result.Approved || len([]byte(result.Message)) > diagnosticsMaxMessageBytes || !utf8.ValidString(result.Message) {
t.Fatalf("Validate() = %#v, %v; want bounded valid UTF-8 rejection", result, err) t.Fatalf("Validate() = %#v, %v; want bounded valid UTF-8 rejection", result, err)
} }
if strings.Count(result.Message, "npcs[") > diagnosticsMaxIssues || !strings.Contains(result.Message, "additional issue(s) omitted") || !strings.Contains(result.Message, `\n\t`) { if strings.Count(result.Message, "npcs[") > diagnosticsMaxIssues || !strings.Contains(result.Message, "additional issue(s) omitted") {
t.Fatalf("message = %q, want bounded quoted diagnostics", result.Message) t.Fatalf("message = %q, want bounded quoted diagnostics", result.Message)
} }
} }
@@ -69,7 +69,7 @@ func TestValidatorDoesNotMutateValue(t *testing.T) {
value := validNPCList() value := validNPCList()
before := value before := value
_, err := New(Options{}).Validate(context.Background(), requestWithValue(value)) _, err := New(Options{}).Validate(context.Background(), requestWithValue(value))
if err != nil || value.NPCs[0].Aliases[0] != before.NPCs[0].Aliases[0] { if err != nil || value.NPCs[0].Name != before.NPCs[0].Name {
t.Fatalf("Validate() mutated value: %#v", value) t.Fatalf("Validate() mutated value: %#v", value)
} }
} }
@@ -80,8 +80,7 @@ func requestWithValue(value dnd.NPCList) contracts.TypedValidationRequest[dnd.NP
func validNPCList() dnd.NPCList { func validNPCList() dnd.NPCList {
return dnd.NPCList{NPCs: []dnd.NPC{{ return dnd.NPCList{NPCs: []dnd.NPC{{
ID: "candidate", Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A guarded ranger.", ID: "candidate", Name: "Mira Thorn",
Relationships: []dnd.NPCRelationship{{Target: "Captain Vale", Relationship: "reports to"}},
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}} }}}
} }

View File

@@ -84,5 +84,5 @@ func validDocument() *source.SourceDocument {
} }
func validNPCList() dnd.NPCList { func validNPCList() dnd.NPCList {
return dnd.NPCList{NPCs: []dnd.NPC{{ID: "candidate", Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}} return dnd.NPCList{NPCs: []dnd.NPC{{ID: "candidate", Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}}
} }

View File

@@ -60,15 +60,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
} }
func npcAppearsInCitedText(citedText string, npc dnd.NPC) bool { func npcAppearsInCitedText(citedText string, npc dnd.NPC) bool {
if shared.ContainsTokenSequence(citedText, npc.Name) { return shared.ContainsTokenSequence(citedText, npc.Name)
return true
}
for _, alias := range npc.Aliases {
if shared.ContainsTokenSequence(citedText, alias) {
return true
}
}
return false
} }
func Spec() pipeline.ValidatorSpec { func Spec() pipeline.ValidatorSpec {

View File

@@ -12,14 +12,14 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
) )
func TestValidatorMatchesCanonicalNamesAndAliasesWithUnicodeVariants(t *testing.T) { func TestValidatorMatchesCanonicalNamesWithUnicodeVariants(t *testing.T) {
value := dnd.NPCList{NPCs: []dnd.NPC{ value := dnd.NPCList{NPCs: []dnd.NPC{
{ID: "one", Name: "O'Rin Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{ {ID: "one", Name: "O'Rin Thorn", SourceRefs: []source.SourceRef{
{SourceID: "session", StartUnitID: 2, EndUnitID: 2}, {SourceID: "session", StartUnitID: 2, EndUnitID: 2},
{SourceID: "session", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session", StartUnitID: 1, EndUnitID: 2},
{SourceID: "session", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session", StartUnitID: 1, EndUnitID: 2},
}}, }},
{ID: "two", Name: "Missing Name", Aliases: []string{"The Greencloak"}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}, {ID: "two", Name: "The Greencloak", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}},
}} }}
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{ doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{
{ID: 1, Kind: "message", Text: " orin\u2003thorn appears."}, {ID: 1, Kind: "message", Text: " orin\u2003thorn appears."},
@@ -27,13 +27,13 @@ func TestValidatorMatchesCanonicalNamesAndAliasesWithUnicodeVariants(t *testing.
}} }}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 0 { if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("Validate() = %#v, %v; want alias/canonical relatedness approval", result, err) t.Fatalf("Validate() = %#v, %v; want canonical-name relatedness approval", result, err)
} }
} }
func TestValidatorWarnsAtMostOncePerNPCForUnrelatedCitations(t *testing.T) { func TestValidatorWarnsAtMostOncePerNPCForUnrelatedCitations(t *testing.T) {
value := dnd.NPCList{NPCs: []dnd.NPC{ value := dnd.NPCList{NPCs: []dnd.NPC{
{ID: "one", Name: "Missing\nName", Aliases: []string{"Also Missing"}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}, {ID: "one", Name: "Missing\nName", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}}},
}} }}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: value}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 1 { if err != nil || !result.Approved || len(result.Warnings) != 1 {
@@ -47,7 +47,7 @@ func TestValidatorWarnsAtMostOncePerNPCForUnrelatedCitations(t *testing.T) {
func TestValidatorDoesNotMatchShortNameSubstring(t *testing.T) { func TestValidatorDoesNotMatchShortNameSubstring(t *testing.T) {
value := dnd.NPCList{NPCs: []dnd.NPC{{ value := dnd.NPCList{NPCs: []dnd.NPC{{
ID: "one", Name: "Art", Aliases: []string{}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, ID: "one", Name: "Art", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}} }}}
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "A cart rolls past."}}} doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "A cart rolls past."}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value})
@@ -62,7 +62,7 @@ func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) {
if err != nil || !result.Approved || len(result.Warnings) != 0 { if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("shape deferral = %#v, %v; want approval without warning", result, err) t.Fatalf("shape deferral = %#v, %v; want approval without warning", result, err)
} }
invalidRange := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Mira Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}} invalidRange := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}}
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidRange}) result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidRange})
if err != nil || !result.Approved || len(result.Warnings) != 0 { if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("invalid-range relatedness = %#v, %v; want approval without warning", result, err) t.Fatalf("invalid-range relatedness = %#v, %v; want approval without warning", result, err)
@@ -70,7 +70,7 @@ func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) {
} }
func TestValidatorUsesOnlyTranscriptEvidenceAndRegistersPolicy(t *testing.T) { func TestValidatorUsesOnlyTranscriptEvidenceAndRegistersPolicy(t *testing.T) {
value := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Opaque NPC", Aliases: []string{}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}} value := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Opaque NPC", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Opaque NPC")}}}}} references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Opaque NPC")}}}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), References: references, Value: value}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), References: references, Value: value})
if err != nil || len(result.Warnings) != 1 { if err != nil || len(result.Warnings) != 1 {

View File

@@ -203,8 +203,6 @@ func validCast(name string) dnd.SpellCast {
return dnd.SpellCast{ return dnd.SpellCast{
Caster: "Aria", Caster: "Aria",
Spell: name, Spell: name,
Effect: "heals an ally",
NarrativeDescription: "Aria restores Borin.",
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
} }
} }

View File

@@ -48,12 +48,6 @@ func Validate(value dnd.SpellList) error {
if strings.TrimSpace(spell.Spell) == "" { if strings.TrimSpace(spell.Spell) == "" {
return fmt.Errorf("spell_casts[%d].spell must not be empty", index) return fmt.Errorf("spell_casts[%d].spell must not be empty", index)
} }
if strings.TrimSpace(spell.Effect) == "" {
return fmt.Errorf("spell_casts[%d].effect must not be empty", index)
}
if strings.TrimSpace(spell.NarrativeDescription) == "" {
return fmt.Errorf("spell_casts[%d].narrative_description must not be empty", index)
}
if len(spell.SourceRefs) == 0 { if len(spell.SourceRefs) == 0 {
return fmt.Errorf("spell_casts[%d].source_refs must not be empty", index) return fmt.Errorf("spell_casts[%d].source_refs must not be empty", index)
} }

Some files were not shown because too many files have changed in this diff Show More