Compare commits
9 Commits
23c55f8925
...
250c5c22b8
| Author | SHA1 | Date | |
|---|---|---|---|
| 250c5c22b8 | |||
| 4b0b166143 | |||
| 90481a0e4b | |||
| 2cbaf20e55 | |||
| a263a0840c | |||
| 14991cf58b | |||
| ab0b4e350c | |||
| 7b2fb0880d | |||
| 748e02db80 |
@@ -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.
|
||||
@@ -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. |
|
||||
| normalize | `noop` | Passes merged typed artifacts through unchanged. |
|
||||
| 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. |
|
||||
| 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/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_relatedness` | deterministic | Emits warnings when an NPC name or alias is not found near its cited source text. |
|
||||
| `normalize/dnd/npcs/identity` | deterministic | Rejects invalid canonical IDs, aliases, and cross-record identity collisions. |
|
||||
| `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 and duplicate canonical-name or ID ownership. |
|
||||
| `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_relatedness` | deterministic | Emits warnings when an actor or declared action is not found near cited source text. |
|
||||
| `normalize/dnd/combat-turns/invariants` | deterministic | Rejects normalized combat-turn identity, target, evidence-order, and chronology violations. |
|
||||
| `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, evidence-order, and chronology violations. |
|
||||
|
||||
The production default chain for `dnd/spells` is used for both its extract and
|
||||
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
|
||||
generated binding is validated at the step handoff and is provided to the
|
||||
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
|
||||
spell evidence. Generated reference identity and bounded producer provenance
|
||||
are recorded by the framework; NPC names, aliases, content, and paths are not
|
||||
copied into manifests or checkpoint decisions. When absent, the prompt receives
|
||||
the exact empty value `{"npcs":[]}` and no registry provenance or fingerprint
|
||||
is recorded.
|
||||
are recorded by the framework; NPC names, content, and paths are not copied
|
||||
into manifests. Consumer-local checkpoint identity uses the names-only
|
||||
projection digest. When absent, the prompt receives the exact empty value
|
||||
`{"npcs":[]}` with its projection digest and no registry provenance.
|
||||
|
||||
The `dnd/spells` normalizer declares the same optional `spell_catalog` slot.
|
||||
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
|
||||
structured `npcs` slot. Campaign references guide only the LLM extraction
|
||||
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
|
||||
registry's source ranges remain provenance for the reference and never become
|
||||
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
|
||||
at operation time. Framework provenance and checkpoint dependencies contain its
|
||||
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
|
||||
`{"npcs":[]}` and no registry provenance or fingerprint is recorded.
|
||||
`{"npcs":[]}` with its projection digest and no registry provenance.
|
||||
|
||||
## State Surfaces
|
||||
|
||||
|
||||
@@ -25,20 +25,8 @@ Each combat turn contains these required fields:
|
||||
| --- | --- |
|
||||
| `actor` | Non-empty string. |
|
||||
| `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. |
|
||||
|
||||
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:
|
||||
|
||||
```json
|
||||
@@ -58,11 +46,10 @@ boundary.
|
||||
|
||||
The codec exposes two representations of the same typed artifact:
|
||||
|
||||
- Candidate encode/decode preserves invalid enum values, nullable values,
|
||||
required-array presence, required strings, targets, and source references so
|
||||
later validators can report them. Candidate decoding still requires valid
|
||||
JSON, one JSON value, known fields, and the explicitly present `round` and
|
||||
`resolution` keys; `null` is distinct from a missing key.
|
||||
- Candidate encode/decode preserves invalid actor and turn-kind values,
|
||||
collection presence, and source references so later validators can report
|
||||
them. Candidate decoding still requires valid JSON, one JSON value, known
|
||||
fields, and compatible JSON types.
|
||||
- Approved encode/decode enforces the structural rules in this contract.
|
||||
|
||||
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
|
||||
handoff.
|
||||
|
||||
The private response envelope has the same fields and JSON types as the durable
|
||||
turn/action shape except that source references contain only `start_unit_id`
|
||||
and `end_unit_id`. It enforces required and nullable field presence, types, and
|
||||
The private response envelope has the same turn fields and JSON types as the
|
||||
durable shape except that source references contain only `start_unit_id`
|
||||
and `end_unit_id`. It enforces required field presence, types, and
|
||||
unknown-field rejection, while deterministic validators own enum membership,
|
||||
non-empty values and collections, and positive-number requirements. The
|
||||
extractor assigns the current source ID, removes exact duplicate ranges, and
|
||||
@@ -113,20 +100,18 @@ The standalone validator keys are:
|
||||
|
||||
| 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_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
|
||||
validator. Relatedness also defers when any cited source range is invalid. It
|
||||
combines overlapping cited ranges once in document order, compares actors with
|
||||
the shared Unicode-aware NPC identity policy, and checks declaration tokens of
|
||||
at least four Unicode code points against complete cited-text tokens. Targets
|
||||
are not checked deterministically.
|
||||
combines overlapping cited ranges once in document order and compares actors
|
||||
with the shared Unicode-aware NPC identity policy.
|
||||
|
||||
The production D&D registrar exposes the extractor and these validators. Its
|
||||
default extraction chain preserves this order: JSON syntax, private response
|
||||
schema, combat shape, source references, then source relatedness.
|
||||
default extraction chain preserves this order: JSON syntax, combat shape,
|
||||
source references, private response schema, then source relatedness.
|
||||
|
||||
## Normalization boundary
|
||||
|
||||
@@ -139,14 +124,12 @@ time handoff. Runtime normalization uses that immutable prepared or handed-off
|
||||
view.
|
||||
|
||||
Normalization policy is `dnd.combat_turns.normalize.v1`. It display-normalizes
|
||||
actor, summary, declarations, targets, and non-null resolutions; canonicalizes
|
||||
exact registry actor and target matches; orders and deduplicates exact source
|
||||
references; stable-sorts records by earliest valid source-document position; and
|
||||
collapses only records with the same actor identity, turn kind, round value, and
|
||||
complete valid evidence set. The first normalized record is retained without
|
||||
merging its actions or prose. Invalid evidence is never eligible for duplicate
|
||||
collapse. Every mutation and collapse emits a bounded warning using the merged
|
||||
input index in its scope.
|
||||
the actor, canonicalizes exact registry actor matches, orders and deduplicates
|
||||
exact source references, stable-sorts records by earliest valid source-document
|
||||
position, and collapses only records with the same actor identity, turn kind,
|
||||
and complete valid evidence set. The first normalized record is retained.
|
||||
Invalid evidence is never eligible for duplicate 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
|
||||
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
|
||||
normalized-invariants validator is
|
||||
`normalize/dnd/combat-turns/invariants`; it defers shape and source-reference
|
||||
failures, then checks display normalization, target identity uniqueness,
|
||||
canonical evidence ordering, chronology, and duplicate identity. It rejects
|
||||
failures, then checks actor display normalization, canonical evidence ordering,
|
||||
chronology, and duplicate identity. It rejects
|
||||
with `invalid_combat_turn_normalization` under policy
|
||||
`dnd.combat_turns.validator.normalized.v1`.
|
||||
|
||||
The production D&D registrar exposes the normalizer and normalized-invariants
|
||||
validator. Its default normalization chain is JSON syntax, durable schema,
|
||||
combat shape, normalized invariants, source references, then source
|
||||
validator. Its default normalization chain is JSON syntax, combat shape,
|
||||
normalized invariants, source references, durable schema, then source
|
||||
relatedness. The lane uses the framework's typed append-order merger and has no
|
||||
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
|
||||
binding contributes artifact kind, schema identity, media type, canonical
|
||||
digest, size, and bounded producer provenance. Consumer metadata and checkpoint
|
||||
fingerprints contain no registry names, aliases, content, paths, or NPC source
|
||||
ranges. The normalized lane is emitted as `lanes/<lane-id>.json` by the JSON
|
||||
fingerprints contain no registry names, content, paths, or NPC source ranges.
|
||||
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
|
||||
companion files.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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
|
||||
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.
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
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;
|
||||
- `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.
|
||||
|
||||
Each relationship contains required `target` and `relationship` strings. Each
|
||||
source reference contains required `source_id`, `start_unit_id`, and
|
||||
Each source reference contains required `source_id`, `start_unit_id`, and
|
||||
`end_unit_id`; unit IDs are positive integers. Source document identity, unit
|
||||
existence, and range ordering are validated by the source-reference validator
|
||||
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
|
||||
assigns deterministic IDs. Extraction validation checks shape, source
|
||||
references, and source relatedness. The normalizer then consolidates records
|
||||
by canonical identity or canonical-name/alias matches, preserves the first
|
||||
record's display and output position, unions relationships and exact evidence,
|
||||
rewrites unambiguous relationship targets to canonical names, and validates
|
||||
the retained registry's identity. No LLM is used for consolidation.
|
||||
only when their normalized canonical names match, preserves the first record's
|
||||
display and output position, unions exact evidence, and validates 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`,
|
||||
`generic/valid_json_schema`, `extract/dnd/npcs/shape`,
|
||||
`extract/dnd/npcs/source_refs`, and
|
||||
`extract/dnd/npcs/source_relatedness`. The normalize chain adds
|
||||
`normalize/dnd/npcs/identity` before the source-reference and relatedness
|
||||
checks. Relatedness emits bounded warnings when an NPC canonical name or
|
||||
alias is not present near its cited transcript text; opaque campaign
|
||||
`extract/dnd/npcs/shape`, `extract/dnd/npcs/source_refs`,
|
||||
`generic/valid_json_schema`, and `extract/dnd/npcs/source_relatedness`. The
|
||||
default normalize chain is `generic/valid_json`, `extract/dnd/npcs/shape`,
|
||||
`normalize/dnd/npcs/identity`, `extract/dnd/npcs/source_refs`,
|
||||
`generic/valid_json_schema`, and `extract/dnd/npcs/source_relatedness`.
|
||||
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.
|
||||
|
||||
## Manifest And Artifact Handoff
|
||||
|
||||
The NPC extractor records prompt and response-schema identities. The durable
|
||||
codec records only `npc_count`; raw names, aliases, descriptions, source
|
||||
references, and payload bytes stay in the lane file rather than manifest
|
||||
codec records only `npc_count`; raw names, source references, and payload bytes
|
||||
stay in the lane file rather than manifest
|
||||
metadata. The normalized lane can be consumed by a later ordered step through
|
||||
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
|
||||
operation-time reference for the spell and combat consumers. Generated
|
||||
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
|
||||
explicit references and retain their file provenance.
|
||||
|
||||
NPC source references are registry provenance and are never accepted as spell
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
source-reference objects omit `source_id`, which the extractor assigns while
|
||||
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.
|
||||
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;
|
||||
- `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
|
||||
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
|
||||
character or creature can be identified. Player and party references may
|
||||
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
|
||||
but does not rewrite them; unknown fields are rejected.
|
||||
|
||||
`effect` and `narrative_description` record the casting declaration and its
|
||||
immediate resolution as established by the transcript. They do not follow
|
||||
summoned creatures, persistent spell effects, or other downstream consequences
|
||||
through the rest of the scene. They also do not correct the table from
|
||||
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.
|
||||
The artifact includes an actual casting event or an unambiguous declared
|
||||
casting attempt. Spell mentions, hypothetical plans, rules discussion, and
|
||||
catalog matches without a casting event are excluded. The spell catalog is a
|
||||
name-recognition policy and never evidence that a cast occurred.
|
||||
|
||||
## 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.
|
||||
|
||||
For each cast, the complete `source_refs` collection identifies the transcript
|
||||
evidence for every factual claim in `caster`, `spell`, `effect`, and
|
||||
`narrative_description`. A cast declaration and its immediate resolution may be
|
||||
cited with separate narrow ranges when intervening units are unrelated. A
|
||||
reported target, roll, amount, condition, interruption, or immediate outcome
|
||||
must be supported by the cited units; otherwise the artifact describes only the
|
||||
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.
|
||||
evidence for the caster, spell name, and occurrence of the cast or declared
|
||||
attempt. The deterministic validators establish that ranges are structurally
|
||||
valid and that the spell name is related to cited text. Semantic evidence
|
||||
sufficiency is an extraction policy and remains subject to evaluation rather
|
||||
than deterministic proof.
|
||||
|
||||
Reference slot keys and accepted file types are defined in
|
||||
[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
|
||||
validated during preparation; an ordered generated binding is validated at the
|
||||
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
|
||||
spell prompt input. It helps the model prefer canonical caster names and
|
||||
recognize aliases; it does not establish that a spell was cast.
|
||||
re-encode canonical durable JSON for registry provenance, and supply only the
|
||||
registry's ordered names as the operation-time spell prompt input. It helps the
|
||||
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
|
||||
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
|
||||
current transcript. Generated provenance records producer and canonical
|
||||
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
|
||||
provenance or NPC checkpoint fingerprint.
|
||||
the prompt receives exactly `{"npcs":[]}` with its projection digest, and the
|
||||
run has no NPC reference provenance.
|
||||
|
||||
## 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
|
||||
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
|
||||
document. Only the first occurrence is retained, in stable order. Its caster,
|
||||
effect, narrative description, and canonical references are preserved without
|
||||
prose merging or source union. Unknown names, empty or invalid evidence, and
|
||||
casts with different evidence remain separate.
|
||||
document. Only the first occurrence is retained, in stable order. Its caster
|
||||
and canonical references are preserved. Unknown names, empty or invalid
|
||||
evidence, and casts with different evidence remain separate.
|
||||
|
||||
Mutation and duplicate decisions are returned through the normal warnings
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
handoff provenance and dependency fingerprint, so the consumer module metadata
|
||||
does not duplicate it.
|
||||
|
||||
@@ -134,9 +134,9 @@ reusable content follows it.
|
||||
|
||||
Accordingly, the common prefix of all three extraction prompts is system,
|
||||
extraction evidence, identity, and campaign references. The NPC prompt then
|
||||
renders task, instructions, and transcript. Spell renders immediate resolution,
|
||||
NPC registry, catalog, task, instructions, and transcript. Combat renders
|
||||
immediate resolution, NPC registry, task, instructions, and transcript. The
|
||||
renders task, instructions, and transcript. Spell renders the NPC registry,
|
||||
catalog, task, instructions, and transcript. Combat renders the NPC registry,
|
||||
task, instructions, and transcript. The
|
||||
scene chunker is not an extraction lane: it retains its separate system,
|
||||
transcript, campaign-reference, task, and instruction order and marks its
|
||||
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
|
||||
structured JSON inputs and private response schemas. Each private response
|
||||
schema remains separate from its durable artifact codec schema; this work does
|
||||
not use shared schema fragments or schema generation. The combat private schema
|
||||
owns the transport envelope—required fields, JSON types, nullability, and
|
||||
unknown-field rejection—while its deterministic validators own semantic
|
||||
constraints such as enum membership, non-empty values and collections, and
|
||||
positive numbers. The spell extractor's prompt declares a required
|
||||
not use shared schema fragments or schema generation. Those private schemas own
|
||||
the transport envelope—required fields, JSON types, nullability, and
|
||||
unknown-field rejection—while deterministic validators own semantic constraints
|
||||
such as enum membership, non-empty values and collections, and positive
|
||||
numbers. The spell extractor's prompt declares a required
|
||||
`application/json` `spell_catalog` input and an optional `application/json`
|
||||
`npcs` input. The extractor generates
|
||||
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
|
||||
registry is bound, the
|
||||
domain registry boundary strictly decodes and identity-validates one durable
|
||||
artifact, re-encodes canonical JSON, and generates a semantic digest over
|
||||
those bytes. The unbound input is exactly `{"npcs":[]}`. Input digests cover
|
||||
the generated bytes; manifests record catalog identity and optional NPC
|
||||
registry digest/count rather than names, aliases, overlay bytes, registry
|
||||
paths, or source metadata. Combat prompt, response-schema, mapping,
|
||||
normalization, identity, and bound-registry fingerprints remain separate
|
||||
semantic inputs to checkpoint identity.
|
||||
artifact, re-encodes canonical JSON for provenance, and separately generates a
|
||||
names-only prompt projection. The unbound projection is exactly `{"npcs":[]}`.
|
||||
Prompt input and component-local checkpoint digests cover the projected bytes;
|
||||
manifests retain the optional full registry digest/count rather than names,
|
||||
overlay bytes, registry paths, or source metadata. Combat prompt,
|
||||
response-schema, mapping, normalization, identity, and registry-projection
|
||||
fingerprints remain separate semantic inputs to checkpoint identity.
|
||||
|
||||
## Debug And Redaction Boundaries
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
by the run-wide extract pool. Production implementations are immutable after
|
||||
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.
|
||||
The registry package resolves one optional normalized artifact through the
|
||||
strict codec, validates whole-registry identity, canonicalizes its JSON, and
|
||||
provides immutable records, prompt input, semantic digest, count, and exact
|
||||
canonical-name/alias lookup. External files cross this boundary during
|
||||
provides immutable records, a names-only prompt projection, distinct durable
|
||||
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
|
||||
the `npcs` slot and its bounded, content-safe validation failures. NPC source
|
||||
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
|
||||
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
|
||||
schema, strict option decoder, injected shared LLM client, and prompt/schema
|
||||
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
|
||||
source evidence.
|
||||
|
||||
The prompt limits each cast to its declaration and immediate resolution; it
|
||||
does not follow summoned creatures, persistent effects, or other downstream
|
||||
consequences through the scene. Shared extraction-evidence and identity rules
|
||||
require transcript-supported factual claims and the most specific in-world
|
||||
caster identity, while campaign references only disambiguate source text.
|
||||
Effects describe the session as played: model rules knowledge cannot supplement
|
||||
or correct the transcript, and nonstandard adjudication is attributed to the GM
|
||||
or table rather than stated as a universal rule. Structural source validation
|
||||
remains deterministic; semantic claim completeness is enforced through
|
||||
extraction policy and evaluation.
|
||||
The prompt includes only actual casting events and unambiguous declared casting
|
||||
attempts. Spell mentions, plans, rules discussion, and catalog matches without
|
||||
a casting event are excluded. Shared extraction-evidence and identity rules
|
||||
require transcript-supported caster and spell facts, while the catalog,
|
||||
campaign references, and NPC names only disambiguate source text. Structural
|
||||
source validation remains deterministic; semantic evidence sufficiency is
|
||||
enforced through extraction policy and evaluation.
|
||||
|
||||
Both the extractor and deterministic catalog validator expose
|
||||
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
|
||||
catalog or catalog composition changes. The extractor additionally fingerprints
|
||||
its complete prompt assets and private response schema, so either semantic
|
||||
contract changing invalidates previously recorded extraction checkpoints. The separate
|
||||
`internal/modules/dnd/codec/spells` package
|
||||
contract changing invalidates previously recorded extraction checkpoints. The
|
||||
separate `internal/modules/dnd/codec/spells` package
|
||||
owns the durable schema and stable JSON representation for artifact kind
|
||||
`dnd/spell-list`. The runner keeps the result typed through validators and later
|
||||
stages, using the codec only for checkpoint, debug, and output boundaries.
|
||||
Shared D&D helpers keep prompt input
|
||||
names and source-unit reference conversion consistent with the scene chunker.
|
||||
Shared D&D helpers keep prompt input names and source-unit reference conversion
|
||||
consistent with the scene chunker.
|
||||
|
||||
The extractor also declares the optional `npcs` registry slot and consumes the
|
||||
immutable registry boundary from `internal/modules/dnd/npcs/registry`. An
|
||||
external registry is prepared before execution; a generated registry is
|
||||
validated and supplied at operation time. External bindings may add only
|
||||
`npc_registry_digest` and `npc_count` to module metadata and an
|
||||
`npc_registry` checkpoint fingerprint. Generated bindings are represented by
|
||||
validated and supplied at operation time. Bound external registries add only
|
||||
the full `npc_registry_digest` and `npc_count` to module metadata. The local
|
||||
`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
|
||||
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
|
||||
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 only for disambiguation and does not consume the optional NPC
|
||||
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
|
||||
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`
|
||||
|
||||
The combat extractor prepares one structured request per supplied chunk using
|
||||
the shared extraction-evidence, identity, campaign-reference,
|
||||
immediate-resolution, NPC-grounding, and transcript prompt inputs. It
|
||||
the shared extraction-evidence, identity, campaign-reference, NPC-grounding,
|
||||
and transcript prompt inputs. It
|
||||
maps the private response to `dnd.CombatTurnList`, assigns the current source
|
||||
identity, removes exact duplicate source ranges, and orders turns by valid
|
||||
source-document position while preserving malformed candidate fields for
|
||||
@@ -269,11 +287,10 @@ for deterministic normalization.
|
||||
### `internal/modules/dnd/normalize/npcs`
|
||||
|
||||
The NPC normalizer performs deterministic identity-aware consolidation in
|
||||
merged input order. It unions only canonical identity or canonical/alias
|
||||
matches, retains the first display record, unions exact relationships and
|
||||
source references, rewrites unambiguous relationship targets, and leaves
|
||||
ambiguous collisions for identity validation. It exposes the identity policy
|
||||
as its local checkpoint fingerprint and emits bounded normalization warnings.
|
||||
merged input order. It consolidates only equal canonical-name comparison keys,
|
||||
retains the first display record, and unions exact source references. It exposes
|
||||
the identity policy as its local checkpoint fingerprint and emits bounded
|
||||
normalization warnings.
|
||||
|
||||
## 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
|
||||
spell, case-folded and whitespace-normalized caster, and complete non-empty
|
||||
valid source-reference set. It retains the first occurrence and its caster,
|
||||
effect, narrative description, and stable order. Unknown names, empty or
|
||||
invalid evidence, and adjacent or overlapping but different ranges remain
|
||||
unchanged for validation.
|
||||
source references, and stable order. Unknown names, empty or invalid evidence,
|
||||
and adjacent or overlapping but different ranges remain unchanged for
|
||||
validation.
|
||||
|
||||
The normalizer exposes the effective catalog digest as its independently scoped
|
||||
`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
|
||||
receives a generated registry at the ordered step handoff, then uses the
|
||||
immutable view during runtime. It display-normalizes combat fields,
|
||||
rewrites exact canonical-name or alias matches for actors and targets, orders
|
||||
and deduplicates source references, stable-sorts records by source-document
|
||||
position, and collapses only exact duplicate identities with fully valid
|
||||
evidence. It deep-clones output storage and emits bounded warnings scoped to
|
||||
merged input indexes. Its metadata and fingerprints identify the normalization
|
||||
and NPC identity policies. External bindings may contribute registry
|
||||
immutable view during runtime. It display-normalizes actors,
|
||||
rewrites canonical-name matches for actors, orders and deduplicates source
|
||||
references, stable-sorts records by source-document position, and collapses
|
||||
only exact duplicate identities with fully valid evidence. It deep-clones
|
||||
output storage and emits bounded warnings scoped to merged input indexes. Its
|
||||
metadata and fingerprints identify the normalization and NPC identity policies.
|
||||
External bindings may contribute registry
|
||||
digest/count metadata; generated identity is retained in framework provenance
|
||||
and dependency fingerprints. The normalizer is included in the production D&D
|
||||
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
|
||||
|
||||
All four validators receive `dnd.SpellList` directly. The shape validator
|
||||
rejects missing or empty spell fields and empty reference lists. The catalog
|
||||
validator defers when shape is invalid, then checks every non-empty spell name
|
||||
against the immutable effective SRD and overlay catalog. It accepts normalized
|
||||
canonical names and aliases without rewriting the artifact; unknown names
|
||||
reject the complete result with bounded, stable index/name diagnostics. The
|
||||
rejects a missing list, blank caster or spell names, and empty reference lists.
|
||||
The catalog validator defers when shape is invalid, then checks every non-empty
|
||||
spell name against the immutable effective SRD and overlay catalog. It accepts
|
||||
normalized canonical names and aliases without rewriting the artifact; unknown
|
||||
names reject the complete result with bounded, stable index/name diagnostics. The
|
||||
source-reference validator defers malformed shapes, validates every cited
|
||||
range, and reports all range defects through a bounded aggregate while
|
||||
preserving `invalid_source_refs`. The relatedness validator resolves all cited
|
||||
@@ -383,34 +400,33 @@ payload rules are defined in the
|
||||
|
||||
## 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
|
||||
current-document identity, unit existence, and range ordering, and reports all
|
||||
defects through bounded aggregates. Source relatedness uses the shared
|
||||
document-order traversal and normalized consecutive-token matching, emitting at
|
||||
most one bounded warning per record when neither the canonical name nor an
|
||||
alias occurs near its cited text. Invalid shape or cited ranges produce no
|
||||
relatedness warnings. Normalize identity validation checks deterministic IDs,
|
||||
canonical names, aliases, and cross-record ownership or canonical collisions.
|
||||
most one bounded warning per record when the canonical name does not occur near
|
||||
its cited text. Invalid shape or cited ranges produce no relatedness warnings.
|
||||
Normalize identity validation checks deterministic IDs, canonical names, and
|
||||
duplicate canonical-name or ID ownership.
|
||||
All are deterministic and expose the policy fingerprints used by the
|
||||
production chains.
|
||||
|
||||
## D&D Combat Validators
|
||||
|
||||
Combat shape validation owns required arrays, strings, nullable values, positive
|
||||
rounds, and supported enums. Combat source-reference validation defers invalid
|
||||
Combat shape validation owns the required list, actor, supported turn kind, and
|
||||
non-empty source-reference collection. Combat source-reference validation defers invalid
|
||||
shape, checks source identity, unit existence, and range order, and reports all
|
||||
defects through bounded aggregates. Combat source-relatedness defers invalid
|
||||
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
|
||||
unrelated actors or declaration text.
|
||||
Actors use normalized consecutive-token matching; declarations retain the
|
||||
minimum four-rune token heuristic. The normalized-invariants
|
||||
validator owns display normalization, comparison-unique targets, canonical
|
||||
an unrelated actor. Actors use normalized consecutive-token matching. The
|
||||
normalized-invariants validator owns actor display normalization, canonical
|
||||
source-reference order, chronology, and exact duplicate identity; it defers
|
||||
shape and source-reference failures. All four validators are deterministic and
|
||||
expose local policy fingerprints. The D&D registrar orders them after generic
|
||||
JSON and response-schema validation at extraction and normalization.
|
||||
expose local policy fingerprints. In the registered defaults, JSON syntax runs
|
||||
first; combat shape, normalized invariants when applicable, and source-reference
|
||||
validation precede JSON Schema validation; warning-only relatedness runs last.
|
||||
|
||||
## Production Registration
|
||||
|
||||
|
||||
@@ -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/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` | 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/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. |
|
||||
@@ -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/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/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/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.
|
||||
External references are strictly decoded and identity-validated during
|
||||
preparation; generated references are decoded and identity-validated at the
|
||||
ordered step handoff. Both paths emit canonical registry JSON to operation-time
|
||||
spell and combat prompt or normalization requests. The framework records
|
||||
generated identity and bounded producer provenance, while the raw external
|
||||
reference remains independently tracked by pipeline provenance. An absent
|
||||
registry is represented only by the empty prompt value `{"npcs":[]}`. Spell
|
||||
ordered step handoff. Both paths retain canonical registry JSON for provenance
|
||||
and emit a names-only projection to operation-time spell and combat prompts.
|
||||
Combat normalization uses the canonical registry for exact actor lookup. The
|
||||
framework records generated identity and bounded producer provenance, while
|
||||
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
|
||||
module contracts.
|
||||
|
||||
|
||||
@@ -193,10 +193,12 @@ the runner returns.
|
||||
|
||||
The pipeline-wide coordinator owns the ordered step loop, generated-reference
|
||||
sets at each barrier, and deterministic merging of step outcomes. For one step,
|
||||
the lane engine initializes checkpoint state in lane order, dispatches bounded
|
||||
extract work, advances terminal lanes through 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.
|
||||
one run-local lane engine owns worker lifecycle, cancellation, dispatch,
|
||||
continuation queues, and result collection. It initializes checkpoint state in
|
||||
lane order, dispatches bounded extract work, advances terminal lanes through
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
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
|
||||
its complete validation chain. A module or validator error becomes a framework
|
||||
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
|
||||
overrides, profiles, and component fingerprints.
|
||||
|
||||
The runner decodes that accepted normalized artifact with the prepared codec,
|
||||
re-encodes it, and requires exact kind, schema identity and digest, media type,
|
||||
canonical bytes, content digest, and producer provenance. A valid result becomes
|
||||
a runner-owned cloned normalized output, restores only normalize-checkpoint
|
||||
warnings, and records one `accepted_artifact_reused` normalize decision. It does
|
||||
not invoke or record extract, merge, normalize, or their validators. Invalid or
|
||||
unavailable accepted state records its decision and fails the producer step;
|
||||
the dependent step never starts and the producer is not implicitly rerun.
|
||||
The runner decodes and canonically re-encodes each reusable artifact once with
|
||||
the prepared codec, requiring exact kind, schema identity and digest, media
|
||||
type, canonical bytes, content digest, and producer provenance. A valid accepted
|
||||
producer becomes a runner-owned cloned normalized output, restores only
|
||||
normalize-checkpoint warnings, and records one `accepted_artifact_reused`
|
||||
normalize decision. It does not invoke or record extract, merge, normalize, or
|
||||
their validators. Invalid or unavailable accepted state records its decision
|
||||
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
|
||||
artifact kind, complete schema identity, media type, canonical content digest,
|
||||
|
||||
@@ -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;
|
||||
diagnostic prose is not classified after the fact. The runner then applies
|
||||
forced-execution policy, validates reusable artifact bytes through the prepared
|
||||
codec, and records the final decision before enforcing a required-predecessor
|
||||
failure. That failure names only the step, lane, and stable reason code. Decision
|
||||
detail passes through one UTF-8-safe bounded sanitizer and contains only
|
||||
allowlisted diagnostic context, never payloads, references, credentials,
|
||||
environment values, or physical paths. Typed categories and codes remain intact
|
||||
through pipeline events and become strings only in manifest and debug-summary
|
||||
JSON. [Operations](../operations.md#resume-and-selective-recompute) is the
|
||||
canonical operator-facing reason-code reference.
|
||||
codec once, returns the canonical hydrated value to the stage, and records the
|
||||
final decision before enforcing a required-predecessor failure. That failure
|
||||
names only the step, lane, and stable reason code. Decision detail is selected
|
||||
from code-owned descriptions by reason code and then UTF-8 normalized and
|
||||
bounded; callers cannot supply arbitrary diagnostic prose. Typed categories and
|
||||
codes remain intact through pipeline events and become strings only in manifest
|
||||
and debug-summary JSON. [Operations](../operations.md#resume-and-selective-recompute)
|
||||
is the canonical operator-facing reason-code reference.
|
||||
|
||||
`internal/core/fileio` provides confined atomic file writes used by state
|
||||
collaborators. The chunk-plan store retains its stronger entry validation.
|
||||
|
||||
@@ -55,7 +55,7 @@ go run ./cmd/notarius run dnd-npc-grounded \
|
||||
--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
|
||||
manifest records generated-reference identity and bounded producer provenance;
|
||||
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. |
|
||||
| `recompute_step` | Selective recomputation forced execution of this lane. |
|
||||
|
||||
Decision detail is bounded explanatory text, not a data-recovery channel. It
|
||||
never contains checkpoint paths, artifact or reference content, source content,
|
||||
credentials, or environment values.
|
||||
Decision detail is bounded explanatory text derived from the stable reason code,
|
||||
not caller-supplied prose or a data-recovery channel. It never contains
|
||||
checkpoint paths, artifact or reference content, source content, credentials,
|
||||
or environment values.
|
||||
|
||||
## Debug Bundles
|
||||
|
||||
|
||||
@@ -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
|
||||
failure, makes D&D validation diagnostics consistently domain-owned, and then
|
||||
aligns the spell and NPC private response boundaries with the same policy.
|
||||
## Objective
|
||||
|
||||
Do not change durable artifact schemas, artifact kinds, public Go types,
|
||||
framework retry behavior, checkpoint formats, or the scene chunker. Existing
|
||||
v1 private-schema identities may be corrected in place because Notarius has not
|
||||
been run in production. Changed prompt and schema content will invalidate
|
||||
development checkpoints through existing fingerprints.
|
||||
Implement the durable contract redesign defined by
|
||||
[Minimal D&D Extraction Contracts](minimal-dnd-extraction-contracts.md) and
|
||||
[ADR-0009](../adr/0009-minimal-evidence-grounded-extraction-artifacts.md).
|
||||
The result is a coordinated in-place redesign of NPC, spell-cast, and combat-turn
|
||||
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,
|
||||
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.
|
||||
## Fixed Decisions And Guardrails
|
||||
|
||||
For typed D&D artifacts, `generic/valid_json` remains first as a representation
|
||||
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.
|
||||
The implementing agent must treat these as decisions, not open design choices:
|
||||
|
||||
Do not expose raw JSON Schema errors or candidate values through the generic
|
||||
validator. Do not add tests that require particular words or phrases to remain
|
||||
in prompt prose.
|
||||
- Keep the artifact kinds `dnd/npc-list`, `dnd/spell-list`, and
|
||||
`dnd/combat-turn-list`, their module keys and capabilities, their media types,
|
||||
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
|
||||
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.
|
||||
### 1.1 Replace the durable and private data shapes
|
||||
|
||||
### 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
|
||||
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.
|
||||
### 1.2 Simplify NPC identity, normalization, and validation
|
||||
|
||||
### 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
|
||||
./internal/modules/dnd/validate/combatturns/... ./internal/modules/dnd/register
|
||||
./internal/modules/integration` and `git diff --check`.
|
||||
### 1.3 Separate durable registry provenance from model input
|
||||
|
||||
## 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
|
||||
every spell and NPC extraction and normalization default chain:
|
||||
- spell extraction/normalization: shape, catalog, source references, schema,
|
||||
then source relatedness;
|
||||
- NPC extraction: shape, source references, schema, then source relatedness;
|
||||
- NPC normalization: shape, identity, source references, schema, then source
|
||||
relatedness.
|
||||
- Keep `generic/valid_json` first and warning-only source relatedness last.
|
||||
- Do not change validator implementations, reason codes, warning promotion,
|
||||
retry counts, or user-provided chain order.
|
||||
- Document the default-chain policy in the pipeline/module internals: domain
|
||||
validators diagnose expected semantic failures and the generic schema
|
||||
validator is the final rejecting representation backstop.
|
||||
- Update NPC codec, schema, extractor, identity, validator, normalizer, registry,
|
||||
registration, pipeline-integration, and CLI fixtures to the new shape. Adapt
|
||||
spell and combat tests that construct `dnd.NPC` values so the repository
|
||||
remains buildable, but do not change their own artifact contracts yet.
|
||||
- Add or rewrite focused tests for strict schema acceptance/rejection,
|
||||
codec round trips, unchanged ID derivation for known names, name-only
|
||||
consolidation, evidence union, registry immutability, and canonical-name-only
|
||||
lookup. Rewrite the existing fixture for the minimal current contract; do not
|
||||
retain the superseded rich fixture solely to test backwards incompatibility.
|
||||
- Test the projection as a data contract: it contains only ordered names,
|
||||
equivalent normalized registries produce identical bytes and digest,
|
||||
evidence/ID-only changes do not change the projection digest, and name/order
|
||||
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.
|
||||
- Add one representative spell and NPC assembled rejection proving that a
|
||||
domain-invalid but encodable candidate is attributed to the owning domain
|
||||
validator rather than the generic schema validator. Do not duplicate each
|
||||
validator package's existing case matrix at integration level.
|
||||
- Confirm explicitly configured validator chains retain their exact configured
|
||||
order.
|
||||
- NPC durable and private schemas expose no removed enrichment fields.
|
||||
- The generated NPC prompt input contains names only while manifests retain
|
||||
full registry provenance.
|
||||
- Spell and combat consumers accept generated or external registries in the
|
||||
redesigned v1 shape; rich pre-redesign registry JSON fails strict decoding.
|
||||
- Focused NPC, spell-wiring, combat-wiring, integration, and CLI tests pass, and
|
||||
`go test ./...` passes.
|
||||
|
||||
### Completion Check
|
||||
## Stage 2: Cut Over Spell-Cast Extraction
|
||||
|
||||
Run `go test ./internal/modules/dnd/register ./internal/modules/integration
|
||||
./internal/framework/pipeline` and `git diff --check`.
|
||||
### 2.1 Replace the spell contract and prompt
|
||||
|
||||
## 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:
|
||||
- retain required fields, JSON types, array/object structure,
|
||||
`additionalProperties: false`, and omission of framework-assigned fields;
|
||||
- remove `minLength`, `minItems`, and positive-number `minimum` constraints;
|
||||
- leave the durable spell and NPC schemas unchanged.
|
||||
- Replace `shared.UnitRef` in the private spell and NPC response DTOs with
|
||||
integer candidates so zero and negative unit IDs survive decoding and mapping
|
||||
into `source.SourceRef` for deterministic source validation.
|
||||
- Update canonicalization and ordering helpers to operate on candidate integers
|
||||
without repairing invalid values. Valid positive IDs retain current output,
|
||||
ordering, and exact-deduplication behavior; invalid ranges remain available
|
||||
to validators.
|
||||
- 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.
|
||||
- Keep the spell shape policy identifier unchanged and validate only list
|
||||
presence, nonblank caster/spell, and nonempty source references.
|
||||
- Keep catalog validation, source-reference validation, and source-relatedness
|
||||
behavior and policy identities unchanged where their actual semantics are
|
||||
already limited to spell name, caster, and evidence.
|
||||
- Simplify normalization and duplicate comparison to caster, catalog-canonical
|
||||
spell name, and the complete valid evidence set. Remove all prose selection,
|
||||
copying, fixture fields, and assertions. Preserve catalog fingerprints and
|
||||
metadata.
|
||||
- Keep the exact inclusion rule from the feature roadmap: an actual casting or
|
||||
unambiguous declared attempt is included; mentions, plans, rules discussion,
|
||||
and catalog matches are not.
|
||||
|
||||
### Tests
|
||||
### 2.3 Update owners and tests
|
||||
|
||||
- For each private schema, prove structurally valid candidates with blank
|
||||
strings, empty required collections, and nonpositive unit IDs pass the
|
||||
private schema, while missing fields, unknown fields, and wrong JSON types do
|
||||
not.
|
||||
- Through raw-JSON LLM fakes, prove semantic values survive decoding and mapping
|
||||
without repair.
|
||||
- Add representative assembled cases showing:
|
||||
- blank or empty spell/NPC fields are rejected by the appropriate shape
|
||||
validator;
|
||||
- nonpositive or nonexistent unit IDs are rejected by the appropriate source
|
||||
validator; and
|
||||
- exhausted validation retries remain non-fatal rejected outputs.
|
||||
- Preserve existing valid mapping, source-position ordering, deduplication,
|
||||
catalog, identity, checkpoint-fingerprint, and durable codec tests.
|
||||
- Update spell codec, private schema, extractor, validators, normalizer, merge
|
||||
and registration tests, pipeline integration, CLI output fixtures, and any
|
||||
maintained examples to the minimal shape. Rewrite or delete rich-schema
|
||||
fixtures rather than retaining them as compatibility cases.
|
||||
- Replace tests of effects and narrative descriptions with focused tests of the
|
||||
retained contract: strict unknown-field rejection, codec round trips, current
|
||||
source-ID assignment, catalog canonicalization, evidence ordering, duplicate
|
||||
collapse, and NPC name-projection wiring.
|
||||
- Update `docs/integrations/dnd-spell-artifacts.md` as the canonical redesigned
|
||||
v1 contract and update current internal module/LLM documentation for the
|
||||
smaller prompt and response. Do not duplicate the spell catalog contract
|
||||
owned by its existing integration document.
|
||||
|
||||
### Completion Check
|
||||
### Stage 2 completion criteria
|
||||
|
||||
Run `go test ./internal/modules/dnd/extract/spells
|
||||
./internal/modules/dnd/extract/npcs ./internal/modules/dnd/validate/spells/...
|
||||
./internal/modules/dnd/validate/npcs/... ./internal/modules/integration` and
|
||||
`git diff --check`.
|
||||
- No production spell type, schema, prompt, validator, normalizer, fixture, or
|
||||
documentation contract refers to effect or narrative description.
|
||||
- Spell prompt/schema identities remain exactly their existing v1 values.
|
||||
- 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
|
||||
git diff --check
|
||||
- Reduce `dnd.CombatTurn` to exactly `Actor`, `TurnKind`, and `SourceRefs`.
|
||||
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 vet ./...
|
||||
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,
|
||||
NPC, or combat schemas own semantic validation. Confirm the scene schema and
|
||||
prompt remain unchanged: their enumerations are explicitly communicated and
|
||||
scene-plan construction has a distinct structural mapping boundary.
|
||||
If the repository-wide race command exposes an unrelated, pre-existing failure,
|
||||
document it with the narrower affected package result; do not weaken or skip
|
||||
ordinary tests for the changed D&D packages.
|
||||
|
||||
### 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
|
||||
|
||||
None. The stages above define the validation ownership, default ordering,
|
||||
compatibility policy, diagnostic behavior, and test boundaries required for
|
||||
implementation.
|
||||
None. The feature roadmap, ADR, and fixed decisions above define the cutover,
|
||||
pre-release schema, evidence, projection, validation, testing, and documentation
|
||||
policies needed to implement each stage without further product decisions.
|
||||
|
||||
220
docs/roadmap/minimal-dnd-extraction-contracts.md
Normal file
220
docs/roadmap/minimal-dnd-extraction-contracts.md
Normal 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.
|
||||
@@ -57,8 +57,8 @@ func TestAssembledSpellPipelineNormalizesMergedCasts(t *testing.T) {
|
||||
t.Fatalf("normalized casts = %#v, want collapsed duplicate plus distinct evidence", normalized.SpellCasts)
|
||||
}
|
||||
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" {
|
||||
t.Fatalf("retained cast = %#v, want canonical spell with first occurrence fields", first)
|
||||
if first.Spell != "Cure Wounds" || first.Caster != " Aria \t" {
|
||||
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}}) {
|
||||
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 req.Chunk.Index == 0 {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
case 0:
|
||||
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
|
||||
case 1:
|
||||
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", Effect: "different evidence", NarrativeDescription: "different narrative", SourceRefs: []source.SourceRef{refTwo}},
|
||||
{Caster: "aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{refOne, refTwo}},
|
||||
{Caster: "aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{refTwo}},
|
||||
}}}, nil
|
||||
default:
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, fmt.Errorf("unexpected assembled chunk index %d", req.Chunk.Index)
|
||||
|
||||
@@ -55,17 +55,17 @@ func TestProductionCombatConfigurationResolvesTypedLane(t *testing.T) {
|
||||
|
||||
wantExtractChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||
}
|
||||
wantNormalizeChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||
pipeline.Binding("normalize/dnd/combat-turns/invariants"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||
}
|
||||
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, wantExtractChain) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/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"}) {
|
||||
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{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/npcs/shape"),
|
||||
pipeline.Binding("extract/dnd/npcs/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/npcs/source_relatedness"),
|
||||
}
|
||||
wantNormalizeChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/npcs/shape"),
|
||||
pipeline.Binding("normalize/dnd/npcs/identity"),
|
||||
pipeline.Binding("extract/dnd/npcs/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/npcs/source_relatedness"),
|
||||
}
|
||||
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, npcextract.Key); !reflect.DeepEqual(got, wantExtractChain) {
|
||||
|
||||
@@ -63,10 +63,10 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
||||
|
||||
wantChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/spells/shape"),
|
||||
pipeline.Binding("extract/dnd/spells/catalog"),
|
||||
pipeline.Binding("extract/dnd/spells/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/spells/source_relatedness"),
|
||||
}
|
||||
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{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||
}
|
||||
combatNormalizeChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||
pipeline.Binding("normalize/dnd/combat-turns/invariants"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
components := productionTestComponents(t)
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
if len(chain.Validators) != 1 || chain.Validators[0].Binding.Module != "generic/always_accept" {
|
||||
t.Fatalf("normalize validator chain = %#v, want explicit always-accept override", chain)
|
||||
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 validator order", chain)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -672,7 +672,7 @@ func (client *productionFakeLLMClient) CompleteStructured(ctx context.Context, r
|
||||
if client.spellResponse != "" {
|
||||
content = []byte(client.spellResponse)
|
||||
} 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:
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)
|
||||
|
||||
@@ -154,9 +154,9 @@ func TestSemanticSpellCatalogFingerprintChangesCheckpointIdentityWithoutReferenc
|
||||
fingerprints := prepared.CheckpointFingerprints()
|
||||
wantNames := map[string]struct{}{
|
||||
"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 + ":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))
|
||||
for _, fingerprint := range fingerprints {
|
||||
|
||||
@@ -148,11 +148,9 @@ func (client *catalogRetryLLMClient) CallCount() int {
|
||||
|
||||
func productionSpellResponse(name string) string {
|
||||
content, err := json.Marshal(dnd.SpellList{SpellCasts: []dnd.SpellCast{{
|
||||
Caster: "Aria",
|
||||
Spell: name,
|
||||
Effect: "The spell takes effect.",
|
||||
NarrativeDescription: "Aria casts the spell.",
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}},
|
||||
Caster: "Aria",
|
||||
Spell: name,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -52,13 +52,13 @@ func (l *FilesystemLoader) Source(moduleKey string) (pipeline.SourceCheckpoint,
|
||||
}
|
||||
doc := cloneSourceDocument(payload.Document)
|
||||
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 {
|
||||
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)) {
|
||||
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()
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (l *FilesystemLoader) ExtractForStep(stepID, laneID, moduleKey string, depe
|
||||
return pipeline.ExtractCheckpoint{}, artifactDecision(err, "extract checkpoint artifact is invalid")
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func (l *FilesystemLoader) MergeForStep(stepID, laneID, moduleKey string, depend
|
||||
return pipeline.MergeCheckpoint{}, artifactDecision(err, "merge checkpoint artifact is invalid")
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func (l *FilesystemLoader) NormalizeForStep(stepID, laneID, moduleKey string, de
|
||||
return pipeline.NormalizeCheckpoint{}, artifactDecision(err, "normalize checkpoint artifact is invalid")
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -158,36 +158,36 @@ func (l *FilesystemLoader) AcceptedNormalize(stepID, laneID, moduleKey string) (
|
||||
return pipeline.NormalizeCheckpoint{}, artifactDecision(err, "accepted normalize checkpoint artifact is invalid")
|
||||
}
|
||||
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)) {
|
||||
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 {
|
||||
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)
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonModuleMismatch, "checkpoint module does not match the requested normalizer")
|
||||
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonModuleMismatch)
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -212,21 +212,21 @@ func artifactCheckpointOutputs(values []artifactCheckpointEnvelope) ([]pipeline.
|
||||
|
||||
func (l *FilesystemLoader) readJSON(name string, out any) pipeline.CheckpointDecision {
|
||||
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)
|
||||
if err != nil {
|
||||
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonPathInvalid, "checkpoint path is invalid")
|
||||
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonPathInvalid)
|
||||
}
|
||||
data, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
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 {
|
||||
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonDecodeFailed, "checkpoint artifact could not be decoded")
|
||||
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonDecodeFailed)
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonWorkspaceSchemaIncompatible, "checkpoint workspace schema is incompatible")
|
||||
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonWorkspaceSchemaIncompatible)
|
||||
}
|
||||
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 {
|
||||
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonIdentityMismatch, "checkpoint identity does not match the current invocation")
|
||||
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonIdentityMismatch)
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonModuleMismatch, "checkpoint module does not match the requested module")
|
||||
return decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonModuleMismatch)
|
||||
}
|
||||
statusOK := false
|
||||
for _, status := range statuses {
|
||||
@@ -268,10 +268,10 @@ func (l *FilesystemLoader) validateLaneManifest(manifest StageManifest, stage St
|
||||
}
|
||||
}
|
||||
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) {
|
||||
return decision(pipeline.CheckpointDecisionDependencyInvalidated, pipeline.CheckpointReasonDependencyMismatch, "checkpoint dependencies do not match")
|
||||
return decision(pipeline.CheckpointDecisionDependencyInvalidated, pipeline.CheckpointReasonDependencyMismatch)
|
||||
}
|
||||
return reusedDecision()
|
||||
}
|
||||
@@ -313,11 +313,11 @@ func fingerprintsEqual(a []pipeline.CheckpointFingerprint, b []pipeline.Checkpoi
|
||||
}
|
||||
|
||||
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 {
|
||||
return pipeline.NewCheckpointDecision(category, code, detail)
|
||||
func decision(category pipeline.CheckpointDecisionCategory, code pipeline.CheckpointReasonCode) pipeline.CheckpointDecision {
|
||||
return pipeline.NewCheckpointDecision(category, code)
|
||||
}
|
||||
|
||||
type artifactPayloadError struct {
|
||||
@@ -332,5 +332,5 @@ func artifactDecision(err error, detail string) pipeline.CheckpointDecision {
|
||||
if payloadErr, ok := err.(*artifactPayloadError); ok {
|
||||
code = payloadErr.code
|
||||
}
|
||||
return decision(pipeline.CheckpointDecisionExecuted, code, detail)
|
||||
return decision(pipeline.CheckpointDecisionExecuted, code)
|
||||
}
|
||||
|
||||
@@ -97,18 +97,18 @@ type CheckpointDecision struct {
|
||||
ReasonCode CheckpointReasonCode `json:"reason_code,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
const checkpointDecisionDetailLimit = 512
|
||||
|
||||
func NewCheckpointDecision(category CheckpointDecisionCategory, reasonCode CheckpointReasonCode, detail string) CheckpointDecision {
|
||||
return checkpointDecision(category, reasonCode, detail)
|
||||
func NewCheckpointDecision(category CheckpointDecisionCategory, reasonCode CheckpointReasonCode) CheckpointDecision {
|
||||
return checkpointDecision(category, reasonCode)
|
||||
}
|
||||
|
||||
func checkpointDecision(category CheckpointDecisionCategory, reasonCode CheckpointReasonCode, detail string) CheckpointDecision {
|
||||
detail = sanitizeCheckpointDecisionDetail(detail)
|
||||
func checkpointDecision(category CheckpointDecisionCategory, reasonCode CheckpointReasonCode) CheckpointDecision {
|
||||
detail := normalizeCheckpointDecisionDetail(checkpointDecisionDetail(reasonCode))
|
||||
return CheckpointDecision{
|
||||
Reused: category == CheckpointDecisionReused,
|
||||
Category: category,
|
||||
@@ -118,12 +118,55 @@ func checkpointDecision(category CheckpointDecisionCategory, reasonCode Checkpoi
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeCheckpointDecisionDetail(detail string) string {
|
||||
detail = strings.TrimSpace(strings.ToValidUTF8(detail, "?"))
|
||||
lower := strings.ToLower(detail)
|
||||
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 decision detail redacted"
|
||||
func checkpointDecisionDetail(reasonCode CheckpointReasonCode) string {
|
||||
switch reasonCode {
|
||||
case CheckpointReasonLoadingDisabled:
|
||||
return "checkpoint loading is disabled"
|
||||
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
|
||||
for _, r := range detail {
|
||||
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 {
|
||||
if policy.forced(stepID, laneID) {
|
||||
return checkpointDecision(CheckpointDecisionForcedRecompute, CheckpointReasonRecomputeStep, "selected step requires execution")
|
||||
return checkpointDecision(CheckpointDecisionForcedRecompute, CheckpointReasonRecomputeStep)
|
||||
}
|
||||
return decision
|
||||
}
|
||||
@@ -184,23 +227,38 @@ func requireReusableCheckpoint(policy CheckpointExecutionPolicy, stepID, laneID
|
||||
|
||||
// resolveCheckpointDecision applies runner policy and canonical payload
|
||||
// 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)
|
||||
resolution := checkpointResolution{decision: decision}
|
||||
if decision.Reused {
|
||||
resolution.values = make([]any, 0, len(artifacts))
|
||||
resolution.artifacts = make([]CheckpointArtifact, 0, len(artifacts))
|
||||
for _, artifact := range artifacts {
|
||||
if _, _, err := decodeCanonicalCheckpointArtifact(codec, artifact); err != nil {
|
||||
decision = checkpointDecision(CheckpointDecisionExecuted, checkpointArtifactReasonCode(err), "stored "+string(stage)+" artifact failed canonical codec validation")
|
||||
value, hydrated, err := decodeCanonicalCheckpointArtifact(codec, artifact)
|
||||
if err != nil {
|
||||
decision = checkpointDecision(CheckpointDecisionExecuted, checkpointArtifactReasonCode(err))
|
||||
resolution.values = nil
|
||||
resolution.artifacts = nil
|
||||
break
|
||||
}
|
||||
resolution.values = append(resolution.values, value)
|
||||
resolution.artifacts = append(resolution.artifacts, hydrated)
|
||||
}
|
||||
}
|
||||
resolution.decision = decision
|
||||
if output != nil {
|
||||
recordCheckpointEvent(output, loader, string(stage), stepID, laneID, moduleKey, decision)
|
||||
}
|
||||
if err := requireReusableCheckpoint(policy, stepID, laneID, decision); err != nil {
|
||||
return decision, err
|
||||
return resolution, err
|
||||
}
|
||||
return decision, nil
|
||||
return resolution, nil
|
||||
}
|
||||
|
||||
type SourceCheckpoint struct {
|
||||
@@ -296,19 +354,19 @@ func (noopCheckpointRecorder) NormalizeFailed(string, string, []CheckpointFinger
|
||||
|
||||
func (noopCheckpointLoader) Enabled() bool { return false }
|
||||
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) {
|
||||
return ExtractCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled, "checkpoint loading disabled")
|
||||
return ExtractCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled)
|
||||
}
|
||||
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) {
|
||||
return NormalizeCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled, "checkpoint loading disabled")
|
||||
return NormalizeCheckpoint{}, checkpointDecision(CheckpointDecisionExecuted, CheckpointReasonLoadingDisabled)
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -33,13 +33,13 @@ func (l *acceptedCheckpointLoader) AcceptedNormalize(stepID, laneID, _ string) (
|
||||
}
|
||||
func (l *acceptedCheckpointLoader) Extract(laneID string, _ string, dependencies []CheckpointFingerprint) (ExtractCheckpoint, CheckpointDecision) {
|
||||
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) {
|
||||
return MergeCheckpoint{}, NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing, "checkpoint missing")
|
||||
return MergeCheckpoint{}, NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing)
|
||||
}
|
||||
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) {
|
||||
@@ -85,7 +85,7 @@ func TestRunnerHydratesRequiredNormalizedArtifact(t *testing.T) {
|
||||
producerKey := CheckpointLaneKey(producer.resolved.StepID, producer.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.acceptedDecision[producerKey] = NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused, "accepted artifact reusable")
|
||||
loader.acceptedDecision[producerKey] = NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused)
|
||||
policy := CheckpointExecutionPolicy{
|
||||
RequireReusableLanes: map[string]struct{}{producerKey: {}},
|
||||
ForcedLanes: map[string]struct{}{consumerKey: {}},
|
||||
@@ -154,12 +154,12 @@ func TestRunnerRejectsInvalidRequiredNormalizedArtifactBeforeConsumer(t *testing
|
||||
mutate func(*CheckpointArtifact)
|
||||
wantCode CheckpointReasonCode
|
||||
}{
|
||||
{"missing", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing, "checkpoint missing"), nil, CheckpointReasonMissing},
|
||||
{"rejected status", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonStatusNotReusable, "status rejected"), nil, CheckpointReasonStatusNotReusable},
|
||||
{"corrupt payload", NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused, "accepted artifact reusable"), 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},
|
||||
{"wrong codec identity", NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused, "accepted artifact reusable"), func(v *CheckpointArtifact) { v.Artifact.Kind = "test/score" }, CheckpointReasonArtifactCodecIncompatible},
|
||||
{"wrong content digest", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonArtifactDigestMismatch, "content digest mismatch"), nil, CheckpointReasonArtifactDigestMismatch},
|
||||
{"missing", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing), nil, CheckpointReasonMissing},
|
||||
{"rejected status", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonStatusNotReusable), nil, CheckpointReasonStatusNotReusable},
|
||||
{"corrupt payload", NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused), func(v *CheckpointArtifact) { v.Artifact.Content = []byte(`{"items":[`) }, CheckpointReasonArtifactPayloadInvalid},
|
||||
{"non canonical", NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused), func(v *CheckpointArtifact) { v.Artifact.Content = []byte(`{"items": ["stored"]}`) }, CheckpointReasonArtifactNotCanonical},
|
||||
{"wrong codec identity", NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused), func(v *CheckpointArtifact) { v.Artifact.Kind = "test/score" }, CheckpointReasonArtifactCodecIncompatible},
|
||||
{"wrong content digest", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonArtifactDigestMismatch), nil, CheckpointReasonArtifactDigestMismatch},
|
||||
}
|
||||
for _, test := range tests {
|
||||
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) {
|
||||
prepared := preparedOrderedPipeline(t, 1,
|
||||
orderedLaneSpec{id: "unrelated", profile: "score"},
|
||||
@@ -247,9 +341,9 @@ func TestForcedRequiredLaneExecutesInsteadOfHydrating(t *testing.T) {
|
||||
producerKey := CheckpointLaneKey(producer.resolved.StepID, producer.resolved.ID)
|
||||
consumerKey := CheckpointLaneKey(consumer.resolved.StepID, consumer.resolved.ID)
|
||||
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.acceptedDecision[producerKey] = NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused, "accepted artifact reusable")
|
||||
loader.acceptedDecision[producerKey] = NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused)
|
||||
policy := CheckpointExecutionPolicy{
|
||||
ForcedLanes: map[string]struct{}{producerKey: {}, consumerKey: {}},
|
||||
RequireReusableLanes: map[string]struct{}{unrelatedKey: {}, producerKey: {}},
|
||||
|
||||
@@ -81,9 +81,15 @@ func (r *Runner) runLanes(parent context.Context, input RunInput, step PreparedP
|
||||
output := RunOutput{Manifest: manifestFromPipeline(input)}
|
||||
states, err := initializeLaneStates(input, step, checkpoints, loader, doc, chunks, &output)
|
||||
if err != nil {
|
||||
if mergeErr := mergeTerminalLaneStates(&output, states); mergeErr != nil {
|
||||
return output, errors.Join(err, mergeErr)
|
||||
}
|
||||
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 {
|
||||
return output, err
|
||||
}
|
||||
@@ -97,183 +103,232 @@ func initializeLaneStates(input RunInput, step PreparedPipelineStep, checkpoints
|
||||
states := make([]*laneExtractState, len(step.lanes))
|
||||
for i, prepared := range step.lanes {
|
||||
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 {
|
||||
return nil, err
|
||||
return states, err
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state, err := hydrateRequiredLane(input, loader, doc, i, prepared)
|
||||
states[i] = state
|
||||
if err != nil {
|
||||
return states, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
state, err := prepareLaneExtract(input, loader, doc, chunks, i, prepared, output)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return states, err
|
||||
}
|
||||
if !state.decision.Reused {
|
||||
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 {
|
||||
return nil, err
|
||||
return states, err
|
||||
}
|
||||
states[i] = state
|
||||
}
|
||||
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) {
|
||||
workerCount := input.ExtractWorkers
|
||||
type laneEngineConfig struct {
|
||||
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 {
|
||||
workerCount = 1
|
||||
}
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
defer cancel()
|
||||
jobs := make(chan extractJob, workerCount)
|
||||
results := make(chan extractJobResult, workerCount)
|
||||
completions := make(chan laneCompletion, len(states))
|
||||
continuations := make(chan *laneExtractState, workerCount)
|
||||
return &laneEngine{
|
||||
runner: r, laneEngineConfig: config, ctx: ctx, cancel: cancel, workerCount: workerCount,
|
||||
jobs: make(chan extractJob, workerCount), results: make(chan extractJobResult, workerCount),
|
||||
completions: make(chan laneCompletion, len(config.states)), 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
|
||||
for i := 0; i < workerCount; i++ {
|
||||
for i := 0; i < e.workerCount; i++ {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
for job := range jobs {
|
||||
if ctx.Err() != nil {
|
||||
for job := range e.jobs {
|
||||
if e.ctx.Err() != nil {
|
||||
continue
|
||||
}
|
||||
result := r.runExtractJob(ctx, input, doc, sourceInput, sessionID, job)
|
||||
results <- result
|
||||
e.results <- e.runner.runExtractJob(e.ctx, e.input, e.doc, e.sourceInput, e.sessionID, job)
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
defer close(jobs)
|
||||
for chunkIndex := range chunks {
|
||||
for laneIndex := range states {
|
||||
state := states[laneIndex]
|
||||
if state.terminal || state.decision.Reused {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case jobs <- extractJob{lane: state, chunk: chunks[chunkIndex]}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() { workers.Wait(); close(results) }()
|
||||
var continuationWorkers sync.WaitGroup
|
||||
for i := 0; i < workerCount; i++ {
|
||||
continuationWorkers.Add(1)
|
||||
go func() {
|
||||
defer continuationWorkers.Done()
|
||||
for state := range continuations {
|
||||
if err := ctx.Err(); err != nil {
|
||||
completions <- laneCompletion{index: state.index, err: err}
|
||||
continue
|
||||
}
|
||||
laneOutput, err := r.continueLane(ctx, input, checkpoints, loader, doc, sourceInput, sessionID, chunks, state)
|
||||
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
|
||||
go e.dispatchExtractJobs()
|
||||
go func() { workers.Wait(); close(e.results) }()
|
||||
}
|
||||
|
||||
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) {
|
||||
completedOutputs := make([]RunOutput, len(states))
|
||||
var runErrors []orderedRunError
|
||||
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)
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case e.jobs <- extractJob{lane: state, chunk: e.chunks[chunkIndex]}:
|
||||
case <-e.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
resultChannel := results
|
||||
for resultChannel != nil || len(pendingContinuations) > 0 || completed < launched {
|
||||
}
|
||||
|
||||
func (e *laneEngine) startContinuationWorkers() {
|
||||
for i := 0; i < e.workerCount; i++ {
|
||||
e.continuationWorkers.Add(1)
|
||||
go func() {
|
||||
defer e.continuationWorkers.Done()
|
||||
for state := range e.continuations {
|
||||
if err := e.ctx.Err(); err != nil {
|
||||
e.completions <- laneCompletion{index: state.index, err: err}
|
||||
continue
|
||||
}
|
||||
laneOutput, err := e.runner.continueLane(e.ctx, e.input, e.checkpoints, e.loader, e.doc, e.sourceInput, e.sessionID, e.chunks, state)
|
||||
e.completions <- laneCompletion{index: state.index, output: laneOutput, err: err}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (e *laneEngine) collect() {
|
||||
resultChannel := (<-chan extractJobResult)(e.results)
|
||||
for resultChannel != nil || len(e.pending) > 0 || e.completed < e.launched {
|
||||
var continuationChannel chan<- *laneExtractState
|
||||
var nextContinuation *laneExtractState
|
||||
if len(pendingContinuations) > 0 && ctx.Err() == nil {
|
||||
continuationChannel = continuations
|
||||
nextContinuation = pendingContinuations[0]
|
||||
} else if ctx.Err() != nil {
|
||||
pendingContinuations = nil
|
||||
if len(e.pending) > 0 && e.ctx.Err() == nil {
|
||||
continuationChannel = e.continuations
|
||||
nextContinuation = e.pending[0]
|
||||
} else if e.ctx.Err() != nil {
|
||||
e.pending = nil
|
||||
}
|
||||
select {
|
||||
case continuationChannel <- nextContinuation:
|
||||
pendingContinuations = pendingContinuations[1:]
|
||||
launched++
|
||||
e.pending = e.pending[1:]
|
||||
e.launched++
|
||||
case result, ok := <-resultChannel:
|
||||
if !ok {
|
||||
resultChannel = nil
|
||||
continue
|
||||
}
|
||||
state := states[result.laneIndex]
|
||||
state.remaining--
|
||||
if result.err != nil {
|
||||
state.failed = true
|
||||
runErrors = append(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)
|
||||
cancel()
|
||||
} else {
|
||||
state.results[result.chunkIndex] = result
|
||||
}
|
||||
if state.remaining == 0 && !state.failed && ctx.Err() == nil {
|
||||
if err := finalizeLaneExtract(checkpoints, input.stepID, state); err != nil {
|
||||
state.failed = true
|
||||
runErrors = append(runErrors, orderedRunError{stage: 0, lane: state.index, chunk: len(chunks), err: err})
|
||||
cancel()
|
||||
} else {
|
||||
pendingContinuations = append(pendingContinuations, 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()
|
||||
}
|
||||
e.handleExtractResult(result)
|
||||
case completion := <-e.completions:
|
||||
e.handleCompletion(completion)
|
||||
}
|
||||
}
|
||||
return completedOutputs, runErrors
|
||||
}
|
||||
|
||||
func hydrateRequiredLane(input RunInput, loader CheckpointLoader, doc *source.SourceDocument, index int, prepared preparedLaneExecutor, output *RunOutput) (*laneExtractState, error) {
|
||||
func (e *laneEngine) handleExtractResult(result extractJobResult) {
|
||||
state := e.states[result.laneIndex]
|
||||
state.remaining--
|
||||
if result.err != nil {
|
||||
state.failed = true
|
||||
e.runErrors = append(e.runErrors, orderedRunError{stage: 0, lane: state.index, chunk: result.chunkIndex, err: result.err})
|
||||
_ = checkpointExtractFailed(e.checkpoints, e.input.stepID, state.prepared.resolved.ID, state.prepared.resolved.Extract.Module, state.deps, result.err)
|
||||
e.cancel()
|
||||
} else {
|
||||
state.results[result.chunkIndex] = result
|
||||
}
|
||||
if state.remaining != 0 || state.failed || e.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := finalizeLaneExtract(e.checkpoints, e.input.stepID, state); err != nil {
|
||||
state.failed = true
|
||||
e.runErrors = append(e.runErrors, orderedRunError{stage: 0, lane: state.index, chunk: len(e.chunks), err: err})
|
||||
e.cancel()
|
||||
return
|
||||
}
|
||||
e.pending = append(e.pending, state)
|
||||
}
|
||||
|
||||
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
|
||||
local := RunOutput{Manifest: manifestFromPipeline(input)}
|
||||
checkpoint, decision := loader.AcceptedNormalize(input.stepID, lane.ID, lane.Normalize.Module)
|
||||
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 {
|
||||
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 mergeErr := mergeLaneOutput(output, local); mergeErr != nil {
|
||||
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)
|
||||
return state, err
|
||||
}
|
||||
hydrated := resolution.artifacts[0]
|
||||
local.Warnings = append(local.Warnings, cloneWarnings(checkpoint.Warnings)...)
|
||||
local.NormalizeOutputs = append(local.NormalizeOutputs, contracts.SerializedOutput{
|
||||
StepID: input.stepID,
|
||||
@@ -282,7 +337,20 @@ func hydrateRequiredLane(input RunInput, loader CheckpointLoader, doc *source.So
|
||||
SourceID: doc.ID,
|
||||
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 {
|
||||
@@ -304,19 +372,16 @@ func prepareLaneExtract(input RunInput, loader CheckpointLoader, doc *source.Sou
|
||||
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))}
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
decision = resolution.decision
|
||||
state.decision = decision
|
||||
if decision.Reused {
|
||||
state.remaining = 0
|
||||
for _, stored := range cp.Outputs {
|
||||
value, hydrated, decodeErr := decodeCanonicalCheckpointArtifact(typed.codec, stored)
|
||||
if decodeErr != nil {
|
||||
return nil, fmt.Errorf("decode extract checkpoint for lane %q: %w", lane.ID, decodeErr)
|
||||
}
|
||||
stored = hydrated
|
||||
for i, stored := range resolution.artifacts {
|
||||
value := resolution.values[i]
|
||||
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{}) {
|
||||
artifact.ChunkRef = chunks[stored.ChunkIndex].Ref
|
||||
|
||||
@@ -227,10 +227,11 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints
|
||||
mergeDeps := append(artifactCheckpointDigests(extracts.serialized), generatedReferenceDependencies(mergeReferences)...)
|
||||
mergeDeps = normalizeCheckpointFingerprints(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 {
|
||||
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 {
|
||||
return stageResult, err
|
||||
}
|
||||
@@ -238,12 +239,8 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints
|
||||
var serializedMerge CheckpointArtifact
|
||||
var mergeWarnings []contracts.Warning
|
||||
if mergeDecision.Reused {
|
||||
value, hydrated, decodeErr := decodeCanonicalCheckpointArtifact(typed.codec, mergeCP.Output)
|
||||
if decodeErr != nil {
|
||||
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
|
||||
merged = erasedMergeArtifact{LaneID: lane.ID, MergerKey: lane.Merge.Module, SourceID: doc.ID, Value: mergeResolution.values[0]}
|
||||
serializedMerge = mergeResolution.artifacts[0]
|
||||
mergeWarnings = cloneWarnings(mergeCP.Warnings)
|
||||
output.Warnings = append(output.Warnings, mergeWarnings...)
|
||||
} else {
|
||||
@@ -327,21 +324,18 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
|
||||
normalizeDeps := append(artifactCheckpointDigests([]CheckpointArtifact{serializedMerge}), generatedReferenceDependencies(normalizeReferences)...)
|
||||
normalizeDeps = normalizeCheckpointFingerprints(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 {
|
||||
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 {
|
||||
return stageResult, err
|
||||
}
|
||||
var serializedNormalize CheckpointArtifact
|
||||
var normalizeWarnings []contracts.Warning
|
||||
if normalizeDecision.Reused {
|
||||
_, hydrated, decodeErr := decodeCanonicalCheckpointArtifact(typed.codec, normalizeCP.Output)
|
||||
if decodeErr != nil {
|
||||
return stageResult, fmt.Errorf("decode normalize checkpoint for lane %q: %w", lane.ID, decodeErr)
|
||||
}
|
||||
serializedNormalize = hydrated
|
||||
serializedNormalize = normalizeResolution.artifacts[0]
|
||||
normalizeWarnings = cloneWarnings(normalizeCP.Warnings)
|
||||
output.Warnings = append(output.Warnings, normalizeWarnings...)
|
||||
} else {
|
||||
|
||||
@@ -28,7 +28,6 @@ func (l requiredCheckpointLoader) AcceptedNormalize(string, string, string) (Nor
|
||||
}
|
||||
|
||||
func TestRequiredCheckpointFailureRetainsDecision(t *testing.T) {
|
||||
const unsafeDetail = "unsafe-loader-detail-/private/checkpoint/path"
|
||||
tests := []struct {
|
||||
name string
|
||||
decision CheckpointDecision
|
||||
@@ -36,10 +35,10 @@ func TestRequiredCheckpointFailureRetainsDecision(t *testing.T) {
|
||||
wantCode CheckpointReasonCode
|
||||
wantAction CheckpointDecisionCategory
|
||||
}{
|
||||
{"missing", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing, unsafeDetail), false, CheckpointReasonMissing, CheckpointDecisionExecuted},
|
||||
{"corrupt", NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonReused, "checkpoint reusable"), true, CheckpointReasonArtifactNotCanonical, CheckpointDecisionExecuted},
|
||||
{"incompatible", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonArtifactCodecIncompatible, unsafeDetail), false, CheckpointReasonArtifactCodecIncompatible, CheckpointDecisionExecuted},
|
||||
{"dependency invalidated", NewCheckpointDecision(CheckpointDecisionDependencyInvalidated, CheckpointReasonDependencyMismatch, unsafeDetail), false, CheckpointReasonDependencyMismatch, CheckpointDecisionDependencyInvalidated},
|
||||
{"missing", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing), false, CheckpointReasonMissing, CheckpointDecisionExecuted},
|
||||
{"corrupt", NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonReused), true, CheckpointReasonArtifactNotCanonical, CheckpointDecisionExecuted},
|
||||
{"incompatible", NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonArtifactCodecIncompatible), false, CheckpointReasonArtifactCodecIncompatible, CheckpointDecisionExecuted},
|
||||
{"dependency invalidated", NewCheckpointDecision(CheckpointDecisionDependencyInvalidated, CheckpointReasonDependencyMismatch), false, CheckpointReasonDependencyMismatch, CheckpointDecisionDependencyInvalidated},
|
||||
}
|
||||
for _, test := range tests {
|
||||
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)) {
|
||||
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
|
||||
for _, event := range output.CheckpointEvents {
|
||||
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 {
|
||||
t.Fatalf("checkpoint event = %#v, want action %q and reason %q", event, test.wantAction, test.wantCode)
|
||||
}
|
||||
if strings.Contains(event.Detail, unsafeDetail) {
|
||||
t.Fatalf("checkpoint event leaked loader detail: %#v", event)
|
||||
if event.Detail == "" || event.Detail != event.Reason {
|
||||
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) {
|
||||
registry := NewArtifactCodecRegistry()
|
||||
if err := RegisterArtifactCodec(registry, notesCodec()); err != nil {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"],
|
||||
"required": ["actor", "turn_kind", "source_refs"],
|
||||
"properties": {
|
||||
"actor": {
|
||||
"type": "string",
|
||||
@@ -20,44 +20,6 @@
|
||||
"type": "string",
|
||||
"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": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
@@ -79,107 +78,21 @@ func (c *Codec) Decode(content []byte) (dnd.CombatTurnList, error) {
|
||||
}
|
||||
|
||||
// DecodeCandidate reads one strict durable JSON value before semantic
|
||||
// validators have approved it. Nullable round and resolution fields are
|
||||
// decoded through raw values so a missing key is not confused with null.
|
||||
// validators have approved it.
|
||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.CombatTurnList, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||
decoder.DisallowUnknownFields()
|
||||
var wire combatTurnListWire
|
||||
if err := decoder.Decode(&wire); err != nil {
|
||||
var value dnd.CombatTurnList
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: multiple JSON values")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
if value.CombatTurns == nil {
|
||||
return fmt.Errorf("combat_turns must be present")
|
||||
@@ -192,38 +105,9 @@ func validate(value dnd.CombatTurnList) error {
|
||||
if !validTurnKind(turn.TurnKind) {
|
||||
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 {
|
||||
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 {
|
||||
refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex)
|
||||
if strings.TrimSpace(ref.SourceID) == "" {
|
||||
@@ -248,12 +132,3 @@ func validTurnKind(value dnd.CombatTurnKind) bool {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,19 +15,8 @@ import (
|
||||
)
|
||||
|
||||
func validList() dnd.CombatTurnList {
|
||||
round := 1
|
||||
resolution := "The wight is hit."
|
||||
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Aria",
|
||||
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.",
|
||||
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
|
||||
}}}
|
||||
}
|
||||
@@ -35,30 +24,26 @@ func validList() dnd.CombatTurnList {
|
||||
func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/dnd_combat_turns.v1.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read durable fixture: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
codec := New()
|
||||
value, err := codec.Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode() error = %v, want nil", err)
|
||||
t.Fatalf("Decode() error = %v", err)
|
||||
}
|
||||
if want := validList(); !reflect.DeepEqual(value, want) {
|
||||
t.Fatalf("Decode() = %#v, want %#v", value, want)
|
||||
}
|
||||
encoded, err := codec.Encode(value)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() error = %v, want nil", err)
|
||||
t.Fatalf("Encode() error = %v", err)
|
||||
}
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, raw); err != nil {
|
||||
t.Fatalf("compact durable fixture: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(encoded, compact.Bytes()) {
|
||||
t.Fatalf("Encode() = %s, want stable durable JSON %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)
|
||||
t.Fatalf("Encode() = %s, want %s", encoded, compact.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,16 +54,11 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
|
||||
t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType())
|
||||
}
|
||||
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()
|
||||
if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec, ok := registry.Spec(dnd.CombatTurnListKind)
|
||||
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
|
||||
@@ -86,176 +66,59 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) {
|
||||
codec := New()
|
||||
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 string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{name: "malformed", raw: `{`, want: "decode dnd combat turn list"},
|
||||
{name: "unknown top-level", raw: `{"combat_turns":[],"unexpected":true}`, want: "unknown field"},
|
||||
{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"},
|
||||
func TestCodecStrictlyRejectsMalformedUnknownAndInvalidJSON(t *testing.T) {
|
||||
validJSON := `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||
tests := []struct{ name, raw, want string }{
|
||||
{"malformed", `{`, "decode dnd combat turn list"},
|
||||
{"unknown top-level", `{"combat_turns":[],"unexpected":true}`, "unknown field"},
|
||||
{"unknown turn field", strings.Replace(validJSON, `"turn_kind":"turn"`, `"turn_kind":"turn","unexpected":true`, 1), "unknown field"},
|
||||
{"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"},
|
||||
{"missing list", `{}`, "combat_turns must be present"},
|
||||
{"unsupported kind", strings.Replace(validJSON, `"turn_kind":"turn"`, `"turn_kind":"unsupported"`, 1), "turn_kind must be supported"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) {
|
||||
round := -1
|
||||
resolution := " "
|
||||
candidate := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: " ",
|
||||
TurnKind: "unsupported",
|
||||
Round: &round,
|
||||
Actions: []dnd.CombatAction{{
|
||||
Category: "unsupported",
|
||||
Declaration: " ",
|
||||
Targets: nil,
|
||||
Resolution: &resolution,
|
||||
}},
|
||||
Summary: " ",
|
||||
SourceRefs: []source.SourceRef{{
|
||||
SourceID: "",
|
||||
StartUnitID: 0,
|
||||
EndUnitID: -1,
|
||||
}},
|
||||
}}}
|
||||
|
||||
content, err := New().EncodeCandidate(candidate)
|
||||
if err != nil || !json.Valid(content) {
|
||||
t.Fatalf("EncodeCandidate() = %s, %v; want JSON", content, err)
|
||||
func TestCodecCandidatePreservesValidatorOwnedValuesAndCollectionPresence(t *testing.T) {
|
||||
candidates := []dnd.CombatTurnList{
|
||||
{},
|
||||
{CombatTurns: []dnd.CombatTurn{}},
|
||||
{CombatTurns: []dnd.CombatTurn{{Actor: " ", TurnKind: "unsupported", SourceRefs: nil}}},
|
||||
{CombatTurns: []dnd.CombatTurn{{Actor: " ", TurnKind: "unsupported", SourceRefs: []source.SourceRef{}}}},
|
||||
{CombatTurns: []dnd.CombatTurn{{Actor: " ", TurnKind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}},
|
||||
}
|
||||
decoded, err := New().DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
for _, candidate := range candidates {
|
||||
content, err := New().EncodeCandidate(candidate)
|
||||
if err != nil || !json.Valid(content) {
|
||||
t.Fatalf("EncodeCandidate() = %s, %v", content, err)
|
||||
}
|
||||
decoded, err := New().DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, 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()
|
||||
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) {
|
||||
func TestCodecRejectsRequiredShapeAndReferenceBoundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value dnd.CombatTurnList
|
||||
want string
|
||||
}{
|
||||
{name: "nil combat turns", value: dnd.CombatTurnList{}, want: "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"},
|
||||
{name: "unsupported turn kind", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].TurnKind = "unsupported" }), want: "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"},
|
||||
{name: "nil actions", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = nil }), want: "actions must contain at least one action"},
|
||||
{name: "empty actions", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Actions = []dnd.CombatAction{} }), want: "actions must contain at least one action"},
|
||||
{name: "empty summary", value: mutate(validList(), func(value *dnd.CombatTurnList) { value.CombatTurns[0].Summary = " " }), want: "summary must not be empty"},
|
||||
{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"},
|
||||
{"nil combat turns", dnd.CombatTurnList{}, "combat_turns must be present"},
|
||||
{"empty actor", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].Actor = " " }), "actor must not be empty"},
|
||||
{"unsupported turn kind", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].TurnKind = "unsupported" }), "turn_kind must be supported"},
|
||||
{"nil source refs", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs = nil }), "source_refs must contain"},
|
||||
{"empty source ID", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"},
|
||||
{"non-positive start", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"},
|
||||
{"non-positive end", mutate(validList(), func(v *dnd.CombatTurnList) { v.CombatTurns[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -270,14 +133,13 @@ func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
|
||||
codec := New()
|
||||
first := codec.Schema()
|
||||
first.JSONSchema[0] = '['
|
||||
second := codec.Schema()
|
||||
if !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' {
|
||||
if second := codec.Schema(); !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' {
|
||||
t.Fatal("Schema() returned shared bytes")
|
||||
}
|
||||
metadata := codec.Metadata(validList())
|
||||
metadata["other"] = true
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}]}]}
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"aliases",
|
||||
"description",
|
||||
"relationships",
|
||||
"source_refs"
|
||||
],
|
||||
"properties": {
|
||||
@@ -27,35 +24,6 @@
|
||||
"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": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
|
||||
@@ -106,29 +106,6 @@ func validate(value dnd.NPCList) error {
|
||||
if strings.TrimSpace(npc.Name) == "" {
|
||||
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 {
|
||||
return fmt.Errorf("%s.source_refs must not be empty", prefix)
|
||||
}
|
||||
|
||||
@@ -17,13 +17,8 @@ import (
|
||||
|
||||
func validList() 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",
|
||||
}},
|
||||
ID: identity.DeriveID("Mira Thorn"),
|
||||
Name: "Mira Thorn",
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
|
||||
}}}
|
||||
}
|
||||
@@ -86,10 +81,10 @@ func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{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: "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 {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -103,7 +98,7 @@ func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) {
|
||||
|
||||
func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) {
|
||||
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)
|
||||
if err != nil || !json.Valid(content) {
|
||||
t.Fatalf("EncodeCandidate() = %s, %v; want JSON", content, err)
|
||||
@@ -124,11 +119,8 @@ func TestCodecRejectsEveryRequiredShapeBoundary(t *testing.T) {
|
||||
value dnd.NPCList
|
||||
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: "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: "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"},
|
||||
{name: "blank name", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: " ", SourceRefs: base.SourceRefs}}}, want: "name 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"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
{
|
||||
"id": "npc:sha256:99a16589618a04f535a7d21fdcc71a0b1c05d22f752cd492065b1086d97bc3d7",
|
||||
"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}]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
"required": [
|
||||
"caster",
|
||||
"spell",
|
||||
"effect",
|
||||
"narrative_description",
|
||||
"source_refs"
|
||||
],
|
||||
"properties": {
|
||||
@@ -26,14 +24,6 @@
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"effect": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"narrative_description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"source_refs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
|
||||
@@ -99,12 +99,6 @@ func validate(value dnd.SpellList) error {
|
||||
if strings.TrimSpace(spell.Spell) == "" {
|
||||
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 {
|
||||
return fmt.Errorf("spell_casts[%d].source_refs must not be empty", index)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
|
||||
t.Fatalf("Decode() error = %v, want nil", err)
|
||||
}
|
||||
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: "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: "Aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}},
|
||||
{Caster: "Borin", Spell: "Fire Bolt", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
|
||||
}}
|
||||
if !reflect.DeepEqual(value, want) {
|
||||
t.Fatalf("Decode() = %#v, want %#v", value, want)
|
||||
@@ -82,9 +82,10 @@ func TestCodecStrictlyRejectsInvalidRepresentations(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{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: "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 {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
{
|
||||
"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",
|
||||
@@ -16,8 +14,6 @@
|
||||
{
|
||||
"caster": "Borin",
|
||||
"spell": "Fire Bolt",
|
||||
"effect": "Scorches the wight.",
|
||||
"narrative_description": "Borin hurls fire at the wight.",
|
||||
"source_refs": [
|
||||
{
|
||||
"source_id": "session-alpha",
|
||||
|
||||
@@ -30,8 +30,6 @@ messages:
|
||||
content_file: ./sharedassets/common-dnd-references.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-immediate-resolution.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-npcs.md
|
||||
cache_control:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
action category values. Set round to null when the transcript does not state an
|
||||
explicit or unambiguous positive round number. Set resolution to null when the
|
||||
transcript establishes the declaration but not an immediate resolution.
|
||||
actor, turn_kind, and source_refs for every record. For turn_kind, use exactly
|
||||
one of: turn, reaction, legendary_action, lair_action, or other. Cite the
|
||||
transcript ranges that establish both the actor and the combat event. Use the
|
||||
players, party, and transcript context to map speakers to in-world actors. NPC
|
||||
names may help disambiguate identity but do not replace transcript evidence.
|
||||
|
||||
@@ -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
|
||||
participant takes a combat turn or performs a discrete interrupting combat
|
||||
event. Reactions, legendary actions, lair actions, and other out-of-turn events
|
||||
belong at the point where they occur in transcript chronology.
|
||||
event. Interrupting events belong at the point where they occur in transcript
|
||||
chronology.
|
||||
|
||||
Exclude initiative setup without a turn or combat event, tactical planning,
|
||||
table talk, rules lookup, hypothetical actions, abandoned declarations, recap
|
||||
of combat outside the current passage, and downstream consequences.
|
||||
table talk, rules lookup, hypothetical events, abandoned intentions, recaps
|
||||
outside the current passage, and downstream consequences.
|
||||
|
||||
Do not infer a round, target, roll, amount, condition, outcome, or action
|
||||
classification from D&D rules knowledge. Preserve the session as played;
|
||||
attribute relevant nonstandard rulings to the GM or table.
|
||||
Do not infer combat events from D&D rules knowledge. Preserve the session as
|
||||
played and 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
|
||||
collection.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"],
|
||||
"required": ["actor", "turn_kind", "source_refs"],
|
||||
"properties": {
|
||||
"actor": {
|
||||
"type": "string"
|
||||
@@ -18,37 +18,6 @@
|
||||
"turn_kind": {
|
||||
"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": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -96,9 +96,6 @@ func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.C
|
||||
turns[index] = dnd.CombatTurn{
|
||||
Actor: turn.Actor,
|
||||
TurnKind: dnd.CombatTurnKind(turn.TurnKind),
|
||||
Round: cloneIntPointer(turn.Round),
|
||||
Actions: canonicalActions(turn.Actions),
|
||||
Summary: turn.Summary,
|
||||
SourceRefs: canonicalSourceRefs(turn.SourceRefs, sourceID),
|
||||
}
|
||||
}
|
||||
@@ -108,22 +105,6 @@ func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.C
|
||||
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 {
|
||||
if refs == nil {
|
||||
return nil
|
||||
@@ -134,19 +115,3 @@ func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []sour
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func referenceSlots() []contracts.ReferenceSlot {
|
||||
slots := shared.ReferenceSlots(referenceSlotDescriptions)
|
||||
slots = append(slots, contracts.ReferenceSlot{
|
||||
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"},
|
||||
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
|
||||
MaxBytes: NPCRegistryMaxBytes,
|
||||
@@ -133,9 +133,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
{Name: "mapping_policy", Value: mappingPolicy},
|
||||
}
|
||||
seeded := e.npcResolver.Seeded()
|
||||
if seeded.Bound() {
|
||||
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.Digest()})
|
||||
}
|
||||
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.ProjectionDigest()})
|
||||
return fingerprints
|
||||
}
|
||||
|
||||
|
||||
@@ -17,26 +17,20 @@ import (
|
||||
)
|
||||
|
||||
func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
|
||||
round := 3
|
||||
resolution := "The ogre falls back."
|
||||
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{
|
||||
{
|
||||
Actor: "Borin", TurnKind: "turn", Round: &round,
|
||||
Actions: []combatActionResponse{{Category: "movement", Declaration: "Borin retreats", Targets: []string{"ogre"}, Resolution: nil}},
|
||||
Summary: "Borin retreats.", SourceRefs: []combatSourceRefResponse{{StartUnitID: 2, EndUnitID: 2}},
|
||||
Actor: "Borin", TurnKind: "turn",
|
||||
SourceRefs: []combatSourceRefResponse{{StartUnitID: 2, EndUnitID: 2}},
|
||||
},
|
||||
{
|
||||
Actor: "Aria", TurnKind: "reaction", Round: nil,
|
||||
Actions: []combatActionResponse{{Category: "attack", Declaration: "Aria strikes", Targets: []string{"ogre"}, Resolution: &resolution}},
|
||||
Summary: "Aria reacts.", SourceRefs: []combatSourceRefResponse{
|
||||
Actor: "Aria", TurnKind: "reaction", SourceRefs: []combatSourceRefResponse{
|
||||
{StartUnitID: 10, EndUnitID: 10},
|
||||
{StartUnitID: 10, EndUnitID: 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
Actor: "Unknown", TurnKind: "other", Round: nil,
|
||||
Actions: []combatActionResponse{{Category: "other", Declaration: "something", Targets: []string{}, Resolution: nil}},
|
||||
Summary: "Uncited event.", SourceRefs: []combatSourceRefResponse{{StartUnitID: 0, EndUnitID: 0}},
|
||||
Actor: "Unknown", TurnKind: "other",
|
||||
SourceRefs: []combatSourceRefResponse{{StartUnitID: 0, EndUnitID: 0}},
|
||||
},
|
||||
}}}
|
||||
|
||||
@@ -51,9 +45,6 @@ func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
|
||||
if !reflect.DeepEqual(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"}) {
|
||||
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) {
|
||||
negativeRound := -1
|
||||
emptyResolution := " "
|
||||
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{
|
||||
{
|
||||
Actor: " ", TurnKind: "unsupported", Round: &negativeRound,
|
||||
Actions: []combatActionResponse{{Category: "unsupported", Declaration: " ", Targets: nil, Resolution: &emptyResolution}},
|
||||
Summary: " ", SourceRefs: []combatSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||
Actor: " ", TurnKind: "unsupported",
|
||||
SourceRefs: []combatSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||
},
|
||||
}}}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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}) {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
metadata := extractor.ManifestMetadata()
|
||||
if _, ok := metadata["npc_registry_digest"]; ok {
|
||||
t.Fatalf("unbound metadata has registry digest: %#v", metadata)
|
||||
}
|
||||
for _, fingerprint := range extractor.CheckpointFingerprints() {
|
||||
if fingerprint.Name == "npc_registry" {
|
||||
t.Fatalf("unbound fingerprints include registry identity: %#v", extractor.CheckpointFingerprints())
|
||||
}
|
||||
if fingerprints := extractor.CheckpointFingerprints(); len(fingerprints) != 4 || fingerprints[3].Name != "npc_registry" || fingerprints[3].Value != input.Digest {
|
||||
t.Fatalf("unbound fingerprints = %#v, want empty-projection identity", fingerprints)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +158,7 @@ func TestExtractorResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin
|
||||
t.Fatalf("Extract() error = %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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])
|
||||
}
|
||||
}
|
||||
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() {
|
||||
if _, ok := wantNames[fingerprint.Name]; !ok {
|
||||
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 {
|
||||
t.Helper()
|
||||
value := dnd.NPCList{NPCs: []dnd.NPC{{
|
||||
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}},
|
||||
}}}
|
||||
value := dnd.NPCList{NPCs: []dnd.NPC{{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: 1, EndUnitID: 1}}}}}
|
||||
content, err := npccodec.New().Encode(value)
|
||||
if err != nil {
|
||||
t.Fatalf("encode NPC registry: %v", err)
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
package combatturns
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type extractionResponse struct {
|
||||
CombatTurns []combatTurnResponse `json:"combat_turns"`
|
||||
}
|
||||
@@ -13,93 +7,10 @@ type extractionResponse struct {
|
||||
type combatTurnResponse struct {
|
||||
Actor string `json:"actor"`
|
||||
TurnKind string `json:"turn_kind"`
|
||||
Round *int `json:"round"`
|
||||
Actions []combatActionResponse `json:"actions"`
|
||||
Summary string `json:"summary"`
|
||||
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 {
|
||||
StartUnitID int `json:"start_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
|
||||
}
|
||||
|
||||
@@ -2,57 +2,20 @@ package combatturns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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
|
||||
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]
|
||||
if turn.Round == nil || *turn.Round != -1 || turn.TurnKind != "unsupported" || turn.Actions[0].Category != "unsupported" || turn.Actions[0].Resolution == nil || *turn.Actions[0].Resolution != "" {
|
||||
t.Fatalf("decoded turn = %#v, want validator-owned values preserved", turn)
|
||||
if turn.Actor != "" || turn.TurnKind != "unsupported" {
|
||||
t.Fatalf("decoded turn = %#v", turn)
|
||||
}
|
||||
if turn.SourceRefs[0] != (combatSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
|
||||
t.Fatalf("decoded source reference = %#v, want nonpositive values preserved", 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)
|
||||
}
|
||||
})
|
||||
t.Fatalf("decoded ref = %#v", turn.SourceRefs[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,13 +46,6 @@ func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *tes
|
||||
turn := semanticCandidate["combat_turns"].([]any)[0].(map[string]any)
|
||||
turn["actor"] = ""
|
||||
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["start_unit_id"] = 0
|
||||
ref["end_unit_id"] = -1
|
||||
@@ -63,7 +56,6 @@ func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *tes
|
||||
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
|
||||
t.Fatalf("private schema rejected validator-owned semantics: %v", err)
|
||||
}
|
||||
turn["actions"] = []any{}
|
||||
turn["source_refs"] = []any{}
|
||||
content, err = json.Marshal(semanticCandidate)
|
||||
if err != nil {
|
||||
@@ -83,12 +75,10 @@ func TestResponseSchemaRetainsStructuralBoundary(t *testing.T) {
|
||||
name string
|
||||
mutate func(map[string]any)
|
||||
}{
|
||||
{name: "missing nullable round", mutate: func(turn map[string]any) { delete(turn, "round") }},
|
||||
{name: "wrong round type", mutate: func(turn map[string]any) { turn["round"] = "one" }},
|
||||
{name: "missing actor", mutate: func(turn map[string]any) { delete(turn, "actor") }},
|
||||
{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: "missing nullable resolution", mutate: func(turn map[string]any) {
|
||||
delete(turn["actions"].([]any)[0].(map[string]any), "resolution")
|
||||
}},
|
||||
{name: "missing source refs", mutate: func(turn map[string]any) { delete(turn, "source_refs") }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := validCombatResponse()
|
||||
@@ -134,11 +124,8 @@ func validCombatResponse() map[string]any {
|
||||
return map[string]any{
|
||||
"combat_turns": []any{
|
||||
map[string]any{
|
||||
"actor": "Aria", "turn_kind": "reaction", "round": nil,
|
||||
"actions": []any{map[string]any{
|
||||
"category": "attack", "declaration": "Aria strikes", "targets": []any{}, "resolution": nil,
|
||||
}},
|
||||
"summary": "Aria reacts.",
|
||||
"actor": "Aria",
|
||||
"turn_kind": "reaction",
|
||||
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -24,7 +24,6 @@ var promptAssetManifest = shared.PromptAssetManifest{
|
||||
"common-dnd-identity.md",
|
||||
"common-dnd-transcript.md",
|
||||
"common-dnd-references.md",
|
||||
"common-dnd-immediate-resolution.md",
|
||||
"common-dnd-npcs.md",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
For every NPC record, cite transcript units that support the canonical name,
|
||||
every alias, the description, and every relationship.
|
||||
For every NPC record, return only the observed display name and transcript
|
||||
units that support that identity.
|
||||
|
||||
Descriptions must be short session records, not biographies, statistics,
|
||||
alignment, motivations, or lore inferred from general D&D knowledge. Do not
|
||||
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 no other details or lore inferred from general D&D knowledge. Do not
|
||||
invent a label for an anonymous creature, crowd, or generic role.
|
||||
|
||||
Return aliases and relationships as arrays, including empty arrays when there
|
||||
are none. Preserve observed display spelling.
|
||||
Preserve observed display spelling. Return at least one narrow source range for
|
||||
every record.
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
Extract a concise Dungeons & Dragons non-player-character registry from the
|
||||
provided transcript.
|
||||
Extract the individually identifiable Dungeons & Dragons non-player characters
|
||||
established by the provided transcript and cite where each identity appears.
|
||||
|
||||
Include an in-world non-PC participant when the transcript establishes that it
|
||||
appears, acts, speaks, or is materially discussed and gives it a proper name,
|
||||
a stable alias or title, or an individually useful distinguishing description.
|
||||
Include an in-world non-PC participant only when the transcript gives it a
|
||||
proper name or a stable, individually distinguishing title or alias.
|
||||
|
||||
Exclude human players, transcript speakers, and the GM as out-of-world people,
|
||||
player characters identified by the player or party references, incidental or
|
||||
hypothetical name drops, corrected transcription mistakes, indistinguishable
|
||||
crowds or groups, 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.
|
||||
hypothetical name drops, corrected transcription mistakes, anonymous or
|
||||
generic roles, indistinguishable crowds or groups, invented descriptive labels,
|
||||
and temporary summoned creatures or spell effects without a persistent
|
||||
individual identity.
|
||||
|
||||
@@ -12,60 +12,24 @@
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"aliases",
|
||||
"description",
|
||||
"relationships",
|
||||
"source_refs"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
"type": "string"
|
||||
},
|
||||
"source_refs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["start_unit_id", "end_unit_id"],
|
||||
"properties": {
|
||||
"start_unit_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
"type": "integer"
|
||||
},
|
||||
"end_unit_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
func canonicalizeResponse(response *extractionResponse, doc *source.SourceDocument) {
|
||||
@@ -33,10 +32,6 @@ func canonicalizeNPC(npc *npcResponse) {
|
||||
if npc == nil {
|
||||
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 {
|
||||
left := npc.SourceRefs[i]
|
||||
right := npc.SourceRefs[j]
|
||||
@@ -48,14 +43,6 @@ func canonicalizeNPC(npc *npcResponse) {
|
||||
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 {
|
||||
if len(refs) < 2 {
|
||||
return refs
|
||||
@@ -73,16 +60,15 @@ func dedupeSourceRefs(refs []npcSourceRefResponse) []npcSourceRefResponse {
|
||||
}
|
||||
|
||||
func sameSourceRef(left npcSourceRefResponse, right npcSourceRefResponse) bool {
|
||||
return left.StartUnitID.Int() == right.StartUnitID.Int() &&
|
||||
left.EndUnitID.Int() == right.EndUnitID.Int()
|
||||
return left.StartUnitID == right.StartUnitID && left.EndUnitID == right.EndUnitID
|
||||
}
|
||||
|
||||
func earliestSourceIndex(doc *source.SourceDocument, npc npcResponse) (int, bool) {
|
||||
earliest := 0
|
||||
found := false
|
||||
for _, ref := range npc.SourceRefs {
|
||||
start := ref.StartUnitID.Int()
|
||||
end := ref.EndUnitID.Int()
|
||||
start := ref.StartUnitID
|
||||
end := ref.EndUnitID
|
||||
if start > 0 && end > 0 {
|
||||
startIndex, startOK := source.UnitIndex(doc, start)
|
||||
endIndex, endOK := source.UnitIndex(doc, end)
|
||||
@@ -98,8 +84,7 @@ func earliestSourceIndex(doc *source.SourceDocument, npc npcResponse) (int, bool
|
||||
return earliest, found
|
||||
}
|
||||
|
||||
func unitSortValue(ref shared.UnitRef) int {
|
||||
value := ref.Int()
|
||||
func unitSortValue(value int) int {
|
||||
if value <= 0 {
|
||||
return int(^uint(0) >> 1)
|
||||
}
|
||||
@@ -113,35 +98,14 @@ func canonicalNPCList(response extractionResponse, sourceID string) dnd.NPCList
|
||||
npcs := make([]dnd.NPC, len(response.NPCs))
|
||||
for index, npc := range response.NPCs {
|
||||
npcs[index] = dnd.NPC{
|
||||
ID: identity.DeriveID(npc.Name),
|
||||
Name: npc.Name,
|
||||
Aliases: cloneStrings(npc.Aliases),
|
||||
Description: npc.Description,
|
||||
Relationships: cloneRelationships(npc.Relationships),
|
||||
SourceRefs: canonicalSourceRefs(npc.SourceRefs, sourceID),
|
||||
ID: identity.DeriveID(npc.Name),
|
||||
Name: npc.Name,
|
||||
SourceRefs: canonicalSourceRefs(npc.SourceRefs, sourceID),
|
||||
}
|
||||
}
|
||||
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 {
|
||||
if values == nil {
|
||||
return nil
|
||||
@@ -150,8 +114,8 @@ func canonicalSourceRefs(values []npcSourceRefResponse, sourceID string) []sourc
|
||||
for index, value := range values {
|
||||
out[index] = source.SourceRef{
|
||||
SourceID: sourceID,
|
||||
StartUnitID: value.StartUnitID.Int(),
|
||||
EndUnitID: value.EndUnitID.Int(),
|
||||
StartUnitID: value.StartUnitID,
|
||||
EndUnitID: value.EndUnitID,
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -11,23 +11,19 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
|
||||
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
|
||||
{
|
||||
Name: "Captain Vale", Aliases: []string{"The Captain"}, Description: "A road captain.",
|
||||
Relationships: []npcRelationshipResponse{{Target: "Mira Thorn", Relationship: "reports to"}},
|
||||
SourceRefs: responseSourceRefs(3, 3),
|
||||
Name: "Captain Vale", SourceRefs: responseSourceRefs(3, 3),
|
||||
},
|
||||
{
|
||||
Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A guarded ranger.",
|
||||
Relationships: []npcRelationshipResponse{{Target: "Captain Vale", Relationship: "commands"}},
|
||||
Name: "Mira Thorn",
|
||||
SourceRefs: []npcSourceRefResponse{
|
||||
{StartUnitID: sharedUnitRef(2), EndUnitID: sharedUnitRef(2)},
|
||||
{StartUnitID: sharedUnitRef(1), EndUnitID: sharedUnitRef(2)},
|
||||
{StartUnitID: sharedUnitRef(1), EndUnitID: sharedUnitRef(2)},
|
||||
{StartUnitID: 2, EndUnitID: 2},
|
||||
{StartUnitID: 1, EndUnitID: 2},
|
||||
{StartUnitID: 1, EndUnitID: 2},
|
||||
},
|
||||
},
|
||||
}}}
|
||||
@@ -37,8 +33,8 @@ func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
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("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("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", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
|
||||
}}
|
||||
if !reflect.DeepEqual(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) {
|
||||
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
|
||||
{
|
||||
Name: "Later NPC", Aliases: []string{}, Description: "Appears later.", Relationships: []npcRelationshipResponse{},
|
||||
Name: "Later NPC",
|
||||
SourceRefs: responseSourceRefs(10, 10),
|
||||
},
|
||||
{
|
||||
Name: "Earlier NPC", Aliases: []string{}, Description: "Appears first.", Relationships: []npcRelationshipResponse{},
|
||||
Name: "Earlier NPC",
|
||||
SourceRefs: []npcSourceRefResponse{
|
||||
{StartUnitID: sharedUnitRef(50), EndUnitID: sharedUnitRef(50)},
|
||||
{StartUnitID: sharedUnitRef(100), EndUnitID: sharedUnitRef(100)},
|
||||
{StartUnitID: 50, EndUnitID: 50},
|
||||
{StartUnitID: 100, EndUnitID: 100},
|
||||
},
|
||||
},
|
||||
}}}
|
||||
@@ -110,14 +106,14 @@ func TestExtractPassesCampaignReferencesAsPromptInputs(t *testing.T) {
|
||||
|
||||
func TestExtractPreservesMalformedCandidatesForValidators(t *testing.T) {
|
||||
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{{
|
||||
Name: "", Aliases: nil, Description: "", Relationships: nil,
|
||||
SourceRefs: []npcSourceRefResponse{{StartUnitID: sharedUnitRef(99), EndUnitID: shared.UnitRefFromString("missing")}, {StartUnitID: sharedUnitRef(99), EndUnitID: shared.UnitRefFromInt(0)}},
|
||||
Name: "",
|
||||
SourceRefs: []npcSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||
}}}}
|
||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
request := extractionRequest()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func sharedUnitRef(value int) shared.UnitRef { return shared.UnitRefFromInt(value) }
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
package npcs
|
||||
|
||||
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
|
||||
type extractionResponse struct {
|
||||
NPCs []npcResponse `json:"npcs"`
|
||||
}
|
||||
|
||||
type npcResponse struct {
|
||||
Name string `json:"name"`
|
||||
Aliases []string `json:"aliases"`
|
||||
Description string `json:"description"`
|
||||
Relationships []npcRelationshipResponse `json:"relationships"`
|
||||
SourceRefs []npcSourceRefResponse `json:"source_refs"`
|
||||
}
|
||||
|
||||
type npcRelationshipResponse struct {
|
||||
Target string `json:"target"`
|
||||
Relationship string `json:"relationship"`
|
||||
Name string `json:"name"`
|
||||
SourceRefs []npcSourceRefResponse `json:"source_refs"`
|
||||
}
|
||||
|
||||
type npcSourceRefResponse struct {
|
||||
StartUnitID shared.UnitRef `json:"start_unit_id"`
|
||||
EndUnitID shared.UnitRef `json:"end_unit_id"`
|
||||
StartUnitID int `json:"start_unit_id"`
|
||||
EndUnitID int `json:"end_unit_id"`
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestLoadResponseSchemaUsesPrivateNPCSchema(t *testing.T) {
|
||||
t.Fatalf("schema = %#v, want private NPC schema identity", schema)
|
||||
}
|
||||
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}},
|
||||
}}}
|
||||
validJSON, err := json.Marshal(valid)
|
||||
@@ -28,8 +28,68 @@ func TestLoadResponseSchemaUsesPrivateNPCSchema(t *testing.T) {
|
||||
if err := validateJSONSchema(validJSON, schema.JSONSchema); err != nil {
|
||||
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{
|
||||
"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}},
|
||||
}}}
|
||||
withIDJSON, err := json.Marshal(withID)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
func extractionRequest() contracts.TypedExtractionRequest {
|
||||
@@ -47,7 +46,7 @@ func sourceDocument() *source.SourceDocument {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -99,9 +98,13 @@ func (client *fakeNPCsLLMClient) CompleteStructured(_ context.Context, req contr
|
||||
if !ok {
|
||||
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target")
|
||||
}
|
||||
*target = client.response
|
||||
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
|
||||
content, err = json.Marshal(client.response)
|
||||
if err != nil {
|
||||
|
||||
@@ -33,8 +33,6 @@ messages:
|
||||
content_file: ./sharedassets/common-dnd-references.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-immediate-resolution.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-npcs.md
|
||||
cache_control:
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
For each spell cast, source references must collectively support the caster,
|
||||
spell, effect, and narrative_description. If a detail is not supported by the
|
||||
cited transcript units, omit that detail or describe only the supported attempt
|
||||
or declaration.
|
||||
For each spell cast, source references must collectively support the in-world
|
||||
caster, spell name, and the fact that the cast or declared attempt occurred.
|
||||
|
||||
For spells, do not follow summoned creatures, persistent effects, or other
|
||||
downstream consequences through the rest of the scene.
|
||||
|
||||
Return only D&D spell-cast artifacts. For each spell cast, identify the
|
||||
in-world caster, spell name, effect, narrative description, and source
|
||||
references.
|
||||
Return only D&D spell-cast artifacts. For each record, identify the in-world
|
||||
caster, canonical spell name, and source references.
|
||||
|
||||
Use the player and party references together with transcript context to map
|
||||
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
|
||||
spell name absent from that catalog, even when it is suggested by general D&D
|
||||
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.
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
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
|
||||
does not identify a spell being cast.
|
||||
|
||||
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.
|
||||
Include an actual casting event or an unambiguous declared casting attempt.
|
||||
Exclude spell mentions, hypothetical plans, rules discussion, and catalog
|
||||
matches that do not establish a casting event in the transcript.
|
||||
|
||||
Use the provided canonical spell-name catalog when naming each extracted spell.
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "notarius.dnd.spells.llm",
|
||||
"$id": "notarius.dnd.spells",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["spell_casts"],
|
||||
@@ -13,47 +13,30 @@
|
||||
"required": [
|
||||
"caster",
|
||||
"spell",
|
||||
"effect",
|
||||
"narrative_description",
|
||||
"source_refs"
|
||||
],
|
||||
"properties": {
|
||||
"caster": {
|
||||
"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."
|
||||
},
|
||||
"spell": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"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": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"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.",
|
||||
"description": "Transcript ranges offered as evidence for the caster, spell name, and casting event in this spell-cast object.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["start_unit_id", "end_unit_id"],
|
||||
"properties": {
|
||||
"start_unit_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
"type": "integer"
|
||||
},
|
||||
"end_unit_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
func canonicalizeResponse(response *extractionResponse) {
|
||||
@@ -29,14 +28,10 @@ func canonicalizeResponse(response *extractionResponse) {
|
||||
}
|
||||
|
||||
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 {
|
||||
left := spell.SourceRefs[i]
|
||||
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.EndUnitID) < unitSortValue(right.EndUnitID)
|
||||
@@ -44,14 +39,6 @@ func canonicalizeSpellCast(spell *spellCastResponse) {
|
||||
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 {
|
||||
if len(refs) < 2 {
|
||||
return refs
|
||||
@@ -69,13 +56,12 @@ func dedupeSourceRefs(refs []spellSourceRefResponse) []spellSourceRefResponse {
|
||||
}
|
||||
|
||||
func sameSourceRef(left spellSourceRefResponse, right spellSourceRefResponse) bool {
|
||||
return left.StartUnitID.Int() == right.StartUnitID.Int() &&
|
||||
left.EndUnitID.Int() == right.EndUnitID.Int()
|
||||
return left.StartUnitID == right.StartUnitID && left.EndUnitID == right.EndUnitID
|
||||
}
|
||||
|
||||
func earliestSourceUnit(spell spellCastResponse) (int, bool) {
|
||||
for _, ref := range spell.SourceRefs {
|
||||
start := ref.StartUnitID.Int()
|
||||
start := ref.StartUnitID
|
||||
if start > 0 {
|
||||
return start, true
|
||||
}
|
||||
@@ -83,8 +69,7 @@ func earliestSourceUnit(spell spellCastResponse) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func unitSortValue(ref shared.UnitRef) int {
|
||||
value := ref.Int()
|
||||
func unitSortValue(value int) int {
|
||||
if value <= 0 {
|
||||
return int(^uint(0) >> 1)
|
||||
}
|
||||
@@ -98,16 +83,14 @@ func canonicalSpellList(response extractionResponse, sourceID string) dnd.SpellL
|
||||
for refIndex, ref := range spell.SourceRefs {
|
||||
refs[refIndex] = source.SourceRef{
|
||||
SourceID: sourceID,
|
||||
StartUnitID: ref.StartUnitID.Int(),
|
||||
EndUnitID: ref.EndUnitID.Int(),
|
||||
StartUnitID: ref.StartUnitID,
|
||||
EndUnitID: ref.EndUnitID,
|
||||
}
|
||||
}
|
||||
spellCasts[index] = dnd.SpellCast{
|
||||
Caster: spell.Caster,
|
||||
Spell: spell.Spell,
|
||||
Effect: spell.Effect,
|
||||
NarrativeDescription: spell.NarrativeDescription,
|
||||
SourceRefs: refs,
|
||||
Caster: spell.Caster,
|
||||
Spell: spell.Spell,
|
||||
SourceRefs: refs,
|
||||
}
|
||||
}
|
||||
if response.SpellCasts == nil {
|
||||
|
||||
@@ -151,9 +151,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||
}
|
||||
seeded := e.npcResolver.Seeded()
|
||||
if seeded.Bound() {
|
||||
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.Digest()})
|
||||
}
|
||||
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.ProjectionDigest()})
|
||||
return fingerprints
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,9 @@ import (
|
||||
func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
|
||||
{
|
||||
Caster: " Aria ",
|
||||
Spell: " Cure Wounds ",
|
||||
Effect: " Heals an injured ally. ",
|
||||
NarrativeDescription: " Aria restores the fighter after the fight. ",
|
||||
SourceRefs: responseSourceRefs(1, 2),
|
||||
Caster: " Aria ",
|
||||
Spell: " Cure Wounds ",
|
||||
SourceRefs: responseSourceRefs(1, 2),
|
||||
},
|
||||
}}}
|
||||
req := extractionRequest()
|
||||
@@ -34,11 +32,9 @@ func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) {
|
||||
}
|
||||
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}},
|
||||
},
|
||||
}}
|
||||
if !reflect.DeepEqual(result.Value, want) {
|
||||
@@ -122,9 +118,10 @@ func TestExtractPromptUsesCanonicalOverlayNamesWithoutAliasesOrMetadata(t *testi
|
||||
"effective_catalog": metadata["catalog_digest"],
|
||||
"prompt": metadata["prompt_sha256"],
|
||||
"response_schema": metadata["response_schema_sha256"],
|
||||
"npc_registry": checkpointFingerprintMap(newExtractor(t, &fakeSpellsLLMClient{}).CheckpointFingerprints())["npc_registry"],
|
||||
}
|
||||
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 {
|
||||
if want, ok := wantFingerprints[fingerprint.Name]; !ok || fingerprint.Value != want {
|
||||
@@ -267,9 +264,9 @@ func TestExtractRejectsInvalidRequests(t *testing.T) {
|
||||
|
||||
func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
|
||||
{Caster: "Borin", Spell: "Fire Bolt", Effect: "Burns.", NarrativeDescription: "Second.", 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: "Narrator", Spell: "Unknown", Effect: "Unknown.", NarrativeDescription: "Uncited."},
|
||||
{Caster: "Borin", Spell: "Fire Bolt", SourceRefs: responseSourceRefs(2, 2)},
|
||||
{Caster: "Aria", Spell: "Cure Wounds", SourceRefs: []spellSourceRefResponse{{StartUnitID: 1, EndUnitID: 2}, {StartUnitID: 1, EndUnitID: 2}}},
|
||||
{Caster: "Narrator", Spell: "Unknown"},
|
||||
}}}
|
||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||
if err != nil {
|
||||
@@ -285,8 +282,8 @@ func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) {
|
||||
|
||||
func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{
|
||||
Caster: "Aria", Spell: "Cure Wounds", Effect: "Heals.", NarrativeDescription: "Aria heals.",
|
||||
SourceRefs: []spellSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(99), EndUnitID: shared.UnitRefFromString("missing")}},
|
||||
Caster: "Aria", Spell: "Cure Wounds",
|
||||
SourceRefs: []spellSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||
}}}}
|
||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
package spells
|
||||
|
||||
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
|
||||
type extractionResponse struct {
|
||||
SpellCasts []spellCastResponse `json:"spell_casts"`
|
||||
}
|
||||
|
||||
type spellCastResponse struct {
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
Effect string `json:"effect"`
|
||||
NarrativeDescription string `json:"narrative_description"`
|
||||
SourceRefs []spellSourceRefResponse `json:"source_refs"`
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
SourceRefs []spellSourceRefResponse `json:"source_refs"`
|
||||
}
|
||||
|
||||
type spellSourceRefResponse struct {
|
||||
StartUnitID shared.UnitRef `json:"start_unit_id"`
|
||||
EndUnitID shared.UnitRef `json:"end_unit_id"`
|
||||
StartUnitID int `json:"start_unit_id"`
|
||||
EndUnitID int `json:"end_unit_id"`
|
||||
}
|
||||
|
||||
@@ -22,10 +22,9 @@ func TestSpellExtractorUsesExactUnboundNPCPromptAndOmitsRegistryIdentity(t *test
|
||||
if metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil {
|
||||
t.Fatalf("unbound extractor metadata = %#v, want no NPC registry fields", metadata)
|
||||
}
|
||||
for _, fingerprint := range extractor.CheckpointFingerprints() {
|
||||
if fingerprint.Name == "npc_registry" {
|
||||
t.Fatalf("unbound checkpoint fingerprints = %#v, want no NPC registry fingerprint", extractor.CheckpointFingerprints())
|
||||
}
|
||||
fingerprints := checkpointFingerprintMap(extractor.CheckpointFingerprints())
|
||||
if fingerprints["npc_registry"] == "" {
|
||||
t.Fatalf("unbound checkpoint fingerprints = %#v, want empty-projection identity", fingerprints)
|
||||
}
|
||||
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
|
||||
@@ -34,7 +33,7 @@ func TestSpellExtractorUsesExactUnboundNPCPromptAndOmitsRegistryIdentity(t *test
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +56,7 @@ func TestSpellExtractorPreservesSemanticNPCRegistryFingerprintAndPromptWiring(t
|
||||
t.Fatalf("semantic NPC fingerprints = %#v and %#v, want same npc_registry value", firstFingerprints, secondFingerprints)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -70,8 +69,8 @@ func TestSpellExtractorPreservesSemanticNPCRegistryFingerprintAndPromptWiring(t
|
||||
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)
|
||||
}
|
||||
if !bytes.Equal(input.Content, canonical) {
|
||||
t.Fatalf("NPC prompt input = %s, want canonical JSON %s", input.Content, canonical)
|
||||
if !bytes.Equal(input.Content, []byte(`{"npcs":[{"name":"Mira Thorn"}]}`)) {
|
||||
t.Fatalf("NPC prompt input = %s, want names-only projection", input.Content)
|
||||
}
|
||||
encoded, err := json.Marshal(map[string]any{"metadata": metadata, "fingerprints": firstFingerprints})
|
||||
if err != nil {
|
||||
@@ -97,28 +96,21 @@ func TestSpellExtractorResolvesOperationNPCOverrideWithoutSingletonMetadata(t *t
|
||||
t.Fatalf("Extract() error = %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
for _, fingerprint := range extractor.CheckpointFingerprints() {
|
||||
if fingerprint.Name == "npc_registry" {
|
||||
t.Fatalf("singleton fingerprints = %#v, want no operation-varying NPC identity", extractor.CheckpointFingerprints())
|
||||
}
|
||||
if checkpointFingerprintMap(extractor.CheckpointFingerprints())["npc_registry"] == "" {
|
||||
t.Fatalf("singleton fingerprints = %#v, want empty-projection identity", extractor.CheckpointFingerprints())
|
||||
}
|
||||
}
|
||||
|
||||
func registryFixture() 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",
|
||||
}},
|
||||
ID: identity.DeriveID("Mira Thorn"),
|
||||
Name: "Mira Thorn",
|
||||
SourceRefs: []source.SourceRef{{SourceID: "npc-session", StartUnitID: 41, EndUnitID: 43}},
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ func TestLoadResponseSchemaUsesExtractorOwnedLLMSchema(t *testing.T) {
|
||||
if !strings.HasPrefix(schema.SHA256, "sha256:") || !json.Valid(schema.JSONSchema) {
|
||||
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()
|
||||
validJSON, err := json.Marshal(valid)
|
||||
@@ -29,6 +36,66 @@ func TestLoadResponseSchemaUsesExtractorOwnedLLMSchema(t *testing.T) {
|
||||
if err := validateJSONSchema(validJSON, schema.JSONSchema); err != nil {
|
||||
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["spell_casts"].([]any)[0].(map[string]any)["source_refs"].([]any)[0].(map[string]any)["source_id"] = "session-alpha"
|
||||
@@ -71,10 +138,8 @@ func validSpellsResponse() map[string]any {
|
||||
return map[string]any{
|
||||
"spell_casts": []any{
|
||||
map[string]any{
|
||||
"caster": "Aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "The wounds close.",
|
||||
"narrative_description": "Aria casts Cure Wounds.",
|
||||
"caster": "Aria",
|
||||
"spell": "Cure Wounds",
|
||||
"source_refs": []any{
|
||||
map[string]any{"start_unit_id": 1, "end_unit_id": 2},
|
||||
},
|
||||
|
||||
@@ -25,7 +25,6 @@ var promptAssetManifest = shared.PromptAssetManifest{
|
||||
"common-dnd-identity.md",
|
||||
"common-dnd-transcript.md",
|
||||
"common-dnd-references.md",
|
||||
"common-dnd-immediate-resolution.md",
|
||||
"common-dnd-npcs.md",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -76,8 +75,8 @@ func mustJSON(t *testing.T, value any) string {
|
||||
func responseSourceRefs(startUnitID int, endUnitID int) []spellSourceRefResponse {
|
||||
return []spellSourceRefResponse{
|
||||
{
|
||||
StartUnitID: shared.UnitRefFromInt(startUnitID),
|
||||
EndUnitID: shared.UnitRefFromInt(endUnitID),
|
||||
StartUnitID: startUnitID,
|
||||
EndUnitID: endUnitID,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -151,9 +150,13 @@ func (client *fakeSpellsLLMClient) CompleteStructured(_ context.Context, req con
|
||||
if !ok {
|
||||
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target")
|
||||
}
|
||||
*target = client.response
|
||||
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
|
||||
content, err = json.Marshal(client.response)
|
||||
if err != nil {
|
||||
|
||||
@@ -22,9 +22,7 @@ const (
|
||||
normalizationPolicy = "dnd.combat_turns.normalize.v1"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
|
||||
ReasonCodeFieldsNormalized = "combat_turn_fields_normalized"
|
||||
ReasonCodeActorCanonicalized = "combat_actor_canonicalized"
|
||||
ReasonCodeTargetCanonicalized = "combat_target_canonicalized"
|
||||
ReasonCodeSourceRefsNormalized = "source_references_normalized"
|
||||
ReasonCodeTurnsReordered = "combat_turns_reordered"
|
||||
ReasonCodeDuplicateCollapsed = "duplicate_combat_turn_collapsed"
|
||||
@@ -92,9 +90,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
{Name: "identity_policy", Value: identity.Policy},
|
||||
}
|
||||
seeded := n.npcResolver.Seeded()
|
||||
if seeded.Bound() {
|
||||
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.Digest()})
|
||||
}
|
||||
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.ProjectionDigest()})
|
||||
return fingerprints
|
||||
}
|
||||
|
||||
@@ -124,11 +120,9 @@ type normalizedRecord struct {
|
||||
hasEvidence bool
|
||||
}
|
||||
|
||||
type targetCanonicalization struct {
|
||||
actionIndex int
|
||||
targetIndex int
|
||||
from string
|
||||
to string
|
||||
type actorCanonicalization struct {
|
||||
from string
|
||||
to string
|
||||
}
|
||||
|
||||
func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) {
|
||||
@@ -139,7 +133,7 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
|
||||
records := make([]normalizedRecord, len(input.CombatTurns))
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
for index, inputTurn := range input.CombatTurns {
|
||||
turn, fieldsChanged, actorChange, targetChanges, refsChanged := normalizeTurn(inputTurn, registry)
|
||||
turn, actorChange, refsChanged := normalizeTurn(inputTurn, registry)
|
||||
earliest, hasEvidence := earliestSourcePosition(doc, turn)
|
||||
records[index] = normalizedRecord{
|
||||
turn: turn,
|
||||
@@ -147,13 +141,6 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
|
||||
earliest: earliest,
|
||||
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 {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
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)),
|
||||
})
|
||||
}
|
||||
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 {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: turnScope(index),
|
||||
@@ -207,87 +185,27 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
|
||||
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.Actor = identity.NormalizeDisplay(input.Actor)
|
||||
output.Summary = identity.NormalizeDisplay(input.Summary)
|
||||
|
||||
var actorChange *targetCanonicalization
|
||||
if canonical, ok := registry.Lookup(output.Actor); ok {
|
||||
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)
|
||||
output.SourceRefs = canonicalRefs
|
||||
refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs)
|
||||
return output, fieldsChanged, actorChange, targetChanges, refsChanged
|
||||
return output, actorChange, refsChanged
|
||||
}
|
||||
|
||||
func cloneCombatTurn(input dnd.CombatTurn) dnd.CombatTurn {
|
||||
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 {
|
||||
output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs))
|
||||
copy(output.SourceRefs, input.SourceRefs)
|
||||
@@ -295,38 +213,6 @@ func cloneCombatTurn(input dnd.CombatTurn) dnd.CombatTurn {
|
||||
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 {
|
||||
if (left == nil) != (right == nil) || len(left) != len(right) {
|
||||
return false
|
||||
@@ -456,12 +342,6 @@ func duplicateKey(turn dnd.CombatTurn, doc *source.SourceDocument) (string, bool
|
||||
var key strings.Builder
|
||||
writeKeyString(&key, identity.ComparisonKey(turn.Actor))
|
||||
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 {
|
||||
writeKeyString(&key, ref.SourceID)
|
||||
writeKeyInt(&key, ref.StartUnitID)
|
||||
@@ -501,7 +381,7 @@ func turnScope(index int) string { return fmt.Sprintf("combat_turns[%d]", index)
|
||||
func referenceSlots() []contracts.ReferenceSlot {
|
||||
return []contracts.ReferenceSlot{{
|
||||
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"},
|
||||
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
|
||||
MaxBytes: NPCRegistryMaxBytes,
|
||||
|
||||
@@ -24,17 +24,9 @@ func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
resolution := " the target is hit "
|
||||
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: " storm ",
|
||||
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 ",
|
||||
Actor: " aria ",
|
||||
TurnKind: dnd.CombatTurnKindTurn,
|
||||
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])
|
||||
@@ -46,17 +38,14 @@ func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
|
||||
if err != nil {
|
||||
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" {
|
||||
t.Fatalf("normalized turn = %#v, want display-normalized fields", 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)
|
||||
if got := result.Value.CombatTurns[0]; got.Actor != "Aria" {
|
||||
t.Fatalf("normalized turn = %#v, want canonical actor", got)
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
t.Fatalf("Normalize() mutated input: got %#v, want %#v", input.CombatTurns[0], original)
|
||||
}
|
||||
result.Value.CombatTurns[0].Actions[0].Targets[0] = "changed"
|
||||
if input.CombatTurns[0].Actions[0].Targets[0] == "changed" {
|
||||
t.Fatal("normalized targets share input storage")
|
||||
result.Value.CombatTurns[0].SourceRefs[0].StartUnitID = 999
|
||||
if input.CombatTurns[0].SourceRefs[0].StartUnitID == 999 {
|
||||
t.Fatal("normalized source refs share input storage")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,10 +66,8 @@ func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Storm",
|
||||
Actor: "aria",
|
||||
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}},
|
||||
}}}
|
||||
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{
|
||||
@@ -92,8 +79,8 @@ func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
turn := result.Value.CombatTurns[0]
|
||||
if turn.Actor != "Aria" || turn.Actions[0].Targets[0] != "Goblin" {
|
||||
t.Fatalf("operation-normalized turn = %#v, want Aria/Goblin", turn)
|
||||
if turn.Actor != "Aria" {
|
||||
t.Fatalf("operation-normalized turn = %#v, want Aria", turn)
|
||||
}
|
||||
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)
|
||||
@@ -103,12 +90,8 @@ func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin
|
||||
func TestNormalizeOrdersBySourcePositionAndCollapsesExactDuplicates(t *testing.T) {
|
||||
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.Summary = "first record"
|
||||
second := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 90, EndUnitID: 90})
|
||||
second.Summary = "later record"
|
||||
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})
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
if !hasWarningReason(result.Warnings, ReasonCodeTurnsReordered) || !hasWarningReason(result.Warnings, ReasonCodeDuplicateCollapsed) {
|
||||
@@ -164,14 +147,12 @@ func TestNormalizePreservesStableOrderForEqualEvidencePositions(t *testing.T) {
|
||||
func TestNormalizeDoesNotCollapseDifferentIdentityDimensions(t *testing.T) {
|
||||
doc := testDocument()
|
||||
base := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10})
|
||||
base.Round = nil
|
||||
tests := []struct {
|
||||
name string
|
||||
other dnd.CombatTurn
|
||||
}{
|
||||
{name: "different actor", other: withActor(base, "Borin")},
|
||||
{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: "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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -291,9 +272,6 @@ func validTurn(actor string, ref source.SourceRef) dnd.CombatTurn {
|
||||
return dnd.CombatTurn{
|
||||
Actor: actor,
|
||||
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},
|
||||
}
|
||||
}
|
||||
@@ -305,8 +283,8 @@ func testDocument() *source.SourceDocument {
|
||||
func npcReferences(t *testing.T) contracts.ReferenceSet {
|
||||
t.Helper()
|
||||
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("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("Aria"), Name: "Aria", 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)
|
||||
if err != nil {
|
||||
@@ -329,28 +307,17 @@ func hasWarningReason(warnings []contracts.Warning, reason string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func intPointer(value int) *int { return &value }
|
||||
|
||||
func withActor(turn dnd.CombatTurn, actor string) dnd.CombatTurn {
|
||||
turn.Actor = actor
|
||||
turn.Actions = cloneCombatTurn(turn).Actions
|
||||
return turn
|
||||
}
|
||||
|
||||
func withKind(turn dnd.CombatTurn, kind dnd.CombatTurnKind) dnd.CombatTurn {
|
||||
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
|
||||
}
|
||||
|
||||
func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn {
|
||||
turn.SourceRefs = []source.SourceRef{ref}
|
||||
turn.Actions = cloneCombatTurn(turn).Actions
|
||||
return turn
|
||||
}
|
||||
|
||||
@@ -22,11 +22,10 @@ const (
|
||||
normalizationPolicy = "dnd.npcs.normalize.v1"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
|
||||
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
|
||||
ReasonCodeNPCIDRecomputed = "npc_id_recomputed"
|
||||
ReasonCodeSourceReferencesNormalized = "source_references_normalized"
|
||||
ReasonCodeDuplicateNPCCollapsed = "duplicate_npc_collapsed"
|
||||
ReasonCodeRelationshipTargetCanonicalized = "relationship_target_canonicalized"
|
||||
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
|
||||
ReasonCodeNPCIDRecomputed = "npc_id_recomputed"
|
||||
ReasonCodeSourceReferencesNormalized = "source_references_normalized"
|
||||
ReasonCodeDuplicateNPCCollapsed = "duplicate_npc_collapsed"
|
||||
)
|
||||
|
||||
var requiredCapabilities = []string{"merged"}
|
||||
@@ -37,23 +36,17 @@ var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
|
||||
|
||||
type Options struct{}
|
||||
|
||||
type Normalizer struct{}
|
||||
|
||||
func New(Options) *Normalizer { return &Normalizer{} }
|
||||
|
||||
func (n *Normalizer) Key() string { return Key }
|
||||
|
||||
func New(Options) *Normalizer { return &Normalizer{} }
|
||||
func (n *Normalizer) Key() string { return Key }
|
||||
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
|
||||
func (n *Normalizer) ManifestMetadata() map[string]any {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"identity_policy": identity.Policy,
|
||||
"normalization_policy": normalizationPolicy,
|
||||
}
|
||||
return map[string]any{"identity_policy": identity.Policy, "normalization_policy": normalizationPolicy}
|
||||
}
|
||||
|
||||
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 {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context error before normalize: %w", err)
|
||||
}
|
||||
|
||||
value, warnings := normalizeList(req.MergeOutput.Value)
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{Value: value, Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
type normalizedRecord struct {
|
||||
npc dnd.NPC
|
||||
fieldsChanged bool
|
||||
referencesChanged bool
|
||||
npc dnd.NPC
|
||||
}
|
||||
|
||||
func normalizeList(input dnd.NPCList) (dnd.NPCList, []contracts.Warning) {
|
||||
if input.NPCs == nil {
|
||||
return dnd.NPCList{}, nil
|
||||
}
|
||||
|
||||
records := make([]normalizedRecord, len(input.NPCs))
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
for index, inputNPC := range input.NPCs {
|
||||
npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC)
|
||||
records[index] = normalizedRecord{
|
||||
npc: npc,
|
||||
fieldsChanged: fieldsChanged,
|
||||
referencesChanged: referencesChanged,
|
||||
}
|
||||
records[index] = normalizedRecord{npc: npc}
|
||||
if fieldsChanged {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: npcScope(index),
|
||||
ReasonCode: ReasonCodeNPCFieldsNormalized,
|
||||
Message: fmt.Sprintf("input index %d: NPC fields normalized for %s",
|
||||
index, diagnostics.Quote(inputNPC.Name)),
|
||||
})
|
||||
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))})
|
||||
}
|
||||
if referencesChanged {
|
||||
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)),
|
||||
})
|
||||
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))})
|
||||
}
|
||||
if inputNPC.ID != npc.ID {
|
||||
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)),
|
||||
})
|
||||
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))})
|
||||
}
|
||||
}
|
||||
|
||||
components := identityComponents(records)
|
||||
output := dnd.NPCList{NPCs: make([]dnd.NPC, 0, len(components))}
|
||||
retainedIndexes := make([]int, 0, len(components))
|
||||
for _, members := range components {
|
||||
consolidated, sourceChanged := consolidate(records, members)
|
||||
groups := canonicalNameGroups(records)
|
||||
output := dnd.NPCList{NPCs: make([]dnd.NPC, 0, len(groups))}
|
||||
for _, members := range groups {
|
||||
consolidated, referencesChanged := consolidate(records, members)
|
||||
retainedIndex := members[0]
|
||||
output.NPCs = append(output.NPCs, consolidated)
|
||||
retainedIndexes = append(retainedIndexes, retainedIndex)
|
||||
|
||||
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 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 len(members) > 1 {
|
||||
warnings = append(warnings, duplicateWarning(retainedIndex, members[1:]))
|
||||
}
|
||||
}
|
||||
|
||||
warnings = append(warnings, canonicalizeRelationshipTargets(&output, retainedIndexes)...)
|
||||
return output, warnings
|
||||
}
|
||||
|
||||
func normalizeRecord(input dnd.NPC) (dnd.NPC, bool, bool) {
|
||||
output := cloneNPC(input)
|
||||
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.ID = identity.DeriveID(output.Name)
|
||||
|
||||
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
|
||||
return output, input.Name != output.Name, !reflect.DeepEqual(input.SourceRefs, output.SourceRefs)
|
||||
}
|
||||
|
||||
func cloneNPC(input dnd.NPC) dnd.NPC {
|
||||
output := input
|
||||
if input.Aliases != nil {
|
||||
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
|
||||
input.SourceRefs = cloneSourceRefs(input.SourceRefs)
|
||||
return input
|
||||
}
|
||||
|
||||
func normalizeAliases(input []string, canonicalName string) []string {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
canonicalKey := identity.ComparisonKey(canonicalName)
|
||||
output := make([]string, 0, len(input))
|
||||
seen := make(map[string]struct{}, len(input))
|
||||
for _, alias := range input {
|
||||
normalized := identity.NormalizeDisplay(alias)
|
||||
key := identity.ComparisonKey(normalized)
|
||||
if canonicalKey != "" && key == canonicalKey {
|
||||
continue
|
||||
func canonicalNameGroups(records []normalizedRecord) [][]int {
|
||||
groups := make([][]int, 0, len(records))
|
||||
ownerByKey := make(map[string]int, len(records))
|
||||
for index, record := range records {
|
||||
key := identity.ComparisonKey(record.npc.Name)
|
||||
if key != "" {
|
||||
if groupIndex, ok := ownerByKey[key]; ok {
|
||||
groups[groupIndex] = append(groups[groupIndex], index)
|
||||
continue
|
||||
}
|
||||
ownerByKey[key] = len(groups)
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
output = append(output, normalized)
|
||||
groups = append(groups, []int{index})
|
||||
}
|
||||
return output
|
||||
return groups
|
||||
}
|
||||
|
||||
func normalizeRelationships(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 {
|
||||
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),
|
||||
func consolidate(records []normalizedRecord, members []int) (dnd.NPC, bool) {
|
||||
output := cloneNPC(records[members[0]].npc)
|
||||
originalRefs := cloneSourceRefs(output.SourceRefs)
|
||||
for _, member := range members[1:] {
|
||||
output.SourceRefs = append(output.SourceRefs, records[member].npc.SourceRefs...)
|
||||
}
|
||||
output.SourceRefs, _, _ = canonicalizeSourceRefs(output.SourceRefs)
|
||||
output.ID = identity.DeriveID(output.Name)
|
||||
return output, !reflect.DeepEqual(originalRefs, output.SourceRefs)
|
||||
}
|
||||
|
||||
func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) {
|
||||
if input == nil {
|
||||
return nil, false, 0
|
||||
}
|
||||
|
||||
canonical := make([]source.SourceRef, len(input))
|
||||
copy(canonical, input)
|
||||
canonical := cloneSourceRefs(input)
|
||||
sort.SliceStable(canonical, func(left, right int) bool {
|
||||
return sourceRefLess(canonical[left], canonical[right])
|
||||
})
|
||||
|
||||
orderChanged := false
|
||||
for index := range input {
|
||||
if input[index] != canonical[index] {
|
||||
orderChanged = true
|
||||
break
|
||||
if canonical[left].SourceID != canonical[right].SourceID {
|
||||
return canonical[left].SourceID < canonical[right].SourceID
|
||||
}
|
||||
}
|
||||
|
||||
if canonical[left].StartUnitID != canonical[right].StartUnitID {
|
||||
return canonical[left].StartUnitID < canonical[right].StartUnitID
|
||||
}
|
||||
return canonical[left].EndUnitID < canonical[right].EndUnitID
|
||||
})
|
||||
orderChanged := !reflect.DeepEqual(input, canonical)
|
||||
unique := make([]source.SourceRef, 0, len(canonical))
|
||||
for _, ref := range canonical {
|
||||
if len(unique) == 0 || unique[len(unique)-1] != ref {
|
||||
@@ -272,219 +178,11 @@ func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool,
|
||||
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 {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
output := make([]source.SourceRef, len(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)
|
||||
return append([]source.SourceRef(nil), input...)
|
||||
}
|
||||
|
||||
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 {
|
||||
indices[index] = strconv.Itoa(removedIndex)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("retained input index %d; removed input indices [%s]", retainedIndex, strings.Join(indices, ", "))
|
||||
if omitted := len(removed) - len(displayed); omitted > 0 {
|
||||
message += fmt.Sprintf("; %d additional removed input indices omitted", omitted)
|
||||
}
|
||||
return contracts.Warning{
|
||||
Scope: npcScope(retainedIndex),
|
||||
ReasonCode: ReasonCodeDuplicateNPCCollapsed,
|
||||
Message: message,
|
||||
}
|
||||
return contracts.Warning{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeDuplicateNPCCollapsed, Message: message}
|
||||
}
|
||||
|
||||
func npcScope(index int) string { return fmt.Sprintf("npcs[%d]", index) }
|
||||
|
||||
func ModuleSpec() pipeline.ModuleSpec {
|
||||
return pipeline.ModuleSpec{
|
||||
Key: Key,
|
||||
Stage: pipeline.StageNormalize,
|
||||
Requires: append([]string(nil), requiredCapabilities...),
|
||||
Provides: append([]string(nil), providedCapabilities...),
|
||||
ArtifactKind: dnd.NPCListKind,
|
||||
}
|
||||
return pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ArtifactKind: dnd.NPCListKind}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.NormalizerRegistry) error {
|
||||
@@ -531,10 +218,7 @@ func Register(registry *pipeline.NormalizerRegistry) error {
|
||||
})
|
||||
}
|
||||
|
||||
func validateOptions(options map[string]any) error {
|
||||
_, err := DecodeOptions(options)
|
||||
return err
|
||||
}
|
||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||
|
||||
func DecodeOptions(options map[string]any) (Options, error) {
|
||||
if err := pipeline.RejectUnknownOptions(options); err != nil {
|
||||
|
||||
@@ -10,223 +10,91 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
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) {
|
||||
if _, err := DecodeOptions(nil); err != nil {
|
||||
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") {
|
||||
t.Fatalf("DecodeOptions() error = %v, want unknown option error", err)
|
||||
}
|
||||
|
||||
want := pipeline.ModuleSpec{
|
||||
Key: Key,
|
||||
Stage: pipeline.StageNormalize,
|
||||
Requires: []string{"merged"},
|
||||
Provides: []string{"normalized"},
|
||||
ArtifactKind: dnd.NPCListKind,
|
||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||
t.Fatal("DecodeOptions() accepted unknown option")
|
||||
}
|
||||
want := pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: dnd.NPCListKind}
|
||||
if got := ModuleSpec(); !reflect.DeepEqual(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()
|
||||
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{})
|
||||
metadata := normalizer.ManifestMetadata()
|
||||
if metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy {
|
||||
t.Fatalf("metadata = %#v, want identity and normalization policies", metadata)
|
||||
if metadata := normalizer.ManifestMetadata(); metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy {
|
||||
t.Fatalf("metadata = %#v", metadata)
|
||||
}
|
||||
fingerprints := normalizer.CheckpointFingerprints()
|
||||
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"
|
||||
wantFingerprints := []pipeline.CheckpointFingerprint{{Name: "identity_policy", Value: identity.Policy}, {Name: "normalization_policy", Value: normalizationPolicy}}
|
||||
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{{
|
||||
ID: "wrong",
|
||||
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{
|
||||
ID: "wrong", Name: " Lady\tAsh ", SourceRefs: []source.SourceRef{
|
||||
{SourceID: "b", StartUnitID: 2, EndUnitID: 3},
|
||||
{SourceID: "a", StartUnitID: 4, EndUnitID: 4},
|
||||
{SourceID: "b", StartUnitID: 2, EndUnitID: 3},
|
||||
},
|
||||
}}}
|
||||
|
||||
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input))
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v, want nil", err)
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
got := result.Value.NPCs[0]
|
||||
if got.Name != "Lady Ash" || got.Description != "first description" {
|
||||
t.Fatalf("normalized fields = %#v, want normalized display and description", got)
|
||||
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 !reflect.DeepEqual(result.Value.NPCs[0], want) {
|
||||
t.Fatalf("NPC = %#v, want %#v", result.Value.NPCs[0], want)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Aliases, []string{"Ash", "L.A."}) {
|
||||
t.Fatalf("aliases = %#v, want canonical alias removal and deduplication", got.Aliases)
|
||||
}
|
||||
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)
|
||||
for _, reason := range []string{ReasonCodeNPCFieldsNormalized, ReasonCodeSourceReferencesNormalized, ReasonCodeNPCIDRecomputed} {
|
||||
if !hasWarning(result.Warnings, reason, "npcs[0]") {
|
||||
t.Fatalf("warnings = %#v, want %s", result.Warnings, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConsolidatesIdentityComponentsInStableOrder(t *testing.T) {
|
||||
func TestNormalizeConsolidatesCanonicalNamesOnlyAndUnionsEvidence(t *testing.T) {
|
||||
input := dnd.NPCList{NPCs: []dnd.NPC{
|
||||
{
|
||||
Name: " Captain Vale ",
|
||||
Description: "first description",
|
||||
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}}},
|
||||
{Name: " Captain Vale ", SourceRefs: []source.SourceRef{{SourceID: "a", StartUnitID: 1, EndUnitID: 1}}},
|
||||
{Name: "captain vale", SourceRefs: []source.SourceRef{{SourceID: "b", StartUnitID: 2, EndUnitID: 2}}},
|
||||
{Name: "The Captain", SourceRefs: []source.SourceRef{{SourceID: "c", StartUnitID: 3, EndUnitID: 3}}},
|
||||
}}
|
||||
|
||||
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input))
|
||||
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 {
|
||||
t.Fatalf("normalized NPC count = %d, want five components", got)
|
||||
if len(result.Value.NPCs) != 2 || result.Value.NPCs[0].Name != "Captain Vale" || result.Value.NPCs[1].Name != "The Captain" {
|
||||
t.Fatalf("NPCs = %#v, want name-only stable consolidation", result.Value.NPCs)
|
||||
}
|
||||
|
||||
first := result.Value.NPCs[0]
|
||||
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 refs := result.Value.NPCs[0].SourceRefs; len(refs) != 2 || refs[0].SourceID != "a" || refs[1].SourceID != "b" {
|
||||
t.Fatalf("source refs = %#v, want evidence union", refs)
|
||||
}
|
||||
if !reflect.DeepEqual(first.SourceRefs, []source.SourceRef{
|
||||
{SourceID: "a", StartUnitID: 1, EndUnitID: 1},
|
||||
{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:])
|
||||
if !hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[0]") {
|
||||
t.Fatalf("warnings = %#v, want duplicate collapse", result.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePreservesInvalidEvidenceAndDoesNotAliasInput(t *testing.T) {
|
||||
invalid := dnd.NPCList{NPCs: []dnd.NPC{{
|
||||
Name: " ",
|
||||
Aliases: []string{""},
|
||||
Description: " ",
|
||||
Relationships: []dnd.NPCRelationship{{Target: " ", Relationship: " "}},
|
||||
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)
|
||||
func TestNormalizePreservesInvalidCandidatesAndDoesNotAliasInput(t *testing.T) {
|
||||
input := dnd.NPCList{NPCs: []dnd.NPC{{Name: " ", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 0, EndUnitID: -1}}}}}
|
||||
before := dnd.NPCList{NPCs: []dnd.NPC{{Name: " ", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 0, EndUnitID: -1}}}}}
|
||||
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input))
|
||||
if err != nil || !reflect.DeepEqual(input, before) {
|
||||
t.Fatalf("Normalize() = %#v, %v; input mutated to %#v", result, err, input)
|
||||
}
|
||||
if !reflect.DeepEqual(invalid, original) {
|
||||
t.Fatalf("normalizer mutated input: got %#v, want %#v", invalid, original)
|
||||
if result.Value.NPCs[0].Name != "" || result.Value.NPCs[0].SourceRefs[0].EndUnitID != -1 {
|
||||
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"
|
||||
if invalid.NPCs[0].Aliases[0] != "" || invalid.NPCs[0].Relationships[0].Target != " " || invalid.NPCs[0].SourceRefs[0].SourceID != "source" {
|
||||
t.Fatalf("output aliases input storage: input = %#v", invalid)
|
||||
if input.NPCs[0].SourceRefs[0].SourceID != "source" {
|
||||
t.Fatal("output aliases input evidence")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,52 +102,17 @@ func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) {
|
||||
normalizer := New(Options{})
|
||||
result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCList{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())
|
||||
cancel()
|
||||
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)
|
||||
}
|
||||
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)
|
||||
t.Fatalf("canceled Normalize() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRequest(value dnd.NPCList) contracts.TypedNormalizeRequest[dnd.NPCList] {
|
||||
return contracts.TypedNormalizeRequest[dnd.NPCList]{
|
||||
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
|
||||
return contracts.TypedNormalizeRequest[dnd.NPCList]{MergeOutput: contracts.MergeArtifact[dnd.NPCList]{Value: value}}
|
||||
}
|
||||
|
||||
func hasWarning(warnings []contracts.Warning, reason, scope string) bool {
|
||||
|
||||
@@ -266,12 +266,12 @@ func TestNormalizeCollapsesDuplicateGroupsAfterCanonicalization(t *testing.T) {
|
||||
firstEvidence := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 2}
|
||||
secondEvidence := source.SourceRef{SourceID: "source", StartUnitID: 3, EndUnitID: 4}
|
||||
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: "Borin", Spell: "Healing Word", Effect: "distinct effect", NarrativeDescription: "distinct narrative", SourceRefs: []source.SourceRef{firstEvidence}},
|
||||
{Caster: " Kyle ", Spell: "Cure Wounds", Effect: "kept effect", NarrativeDescription: "kept narrative", SourceRefs: []source.SourceRef{firstEvidence}},
|
||||
{Caster: "aria", Spell: " cure wounds ", Effect: "removed effect", NarrativeDescription: "removed narrative", 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", Effect: "removed effect three", NarrativeDescription: "removed narrative three", SourceRefs: []source.SourceRef{firstEvidence}},
|
||||
{Caster: " Aria \t", Spell: " cure wounds ", SourceRefs: []source.SourceRef{secondEvidence, firstEvidence, firstEvidence}},
|
||||
{Caster: "Borin", Spell: "Healing Word", SourceRefs: []source.SourceRef{firstEvidence}},
|
||||
{Caster: " Kyle ", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{firstEvidence}},
|
||||
{Caster: "aria", Spell: " cure wounds ", SourceRefs: []source.SourceRef{firstEvidence, secondEvidence}},
|
||||
{Caster: "KYLE", Spell: " cure wounds ", SourceRefs: []source.SourceRef{firstEvidence}},
|
||||
{Caster: " kyle ", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{firstEvidence}},
|
||||
}}
|
||||
|
||||
result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
||||
@@ -281,8 +281,8 @@ func TestNormalizeCollapsesDuplicateGroupsAfterCanonicalization(t *testing.T) {
|
||||
if len(result.Value.SpellCasts) != 3 {
|
||||
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" {
|
||||
t.Fatalf("retained first cast = %#v, want first occurrence fields unchanged", got)
|
||||
if got := result.Value.SpellCasts[0]; got.Caster != " Aria \t" {
|
||||
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}) {
|
||||
t.Fatalf("retained first evidence = %#v, want canonical first evidence only", got)
|
||||
@@ -393,11 +393,9 @@ func TestNormalizeBoundsDuplicateWarningIndices(t *testing.T) {
|
||||
func TestNormalizeIsIdempotentForAlreadyNormalizedInput(t *testing.T) {
|
||||
doc := sourceDocument(3)
|
||||
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
|
||||
Spell: "Cure Wounds",
|
||||
Caster: "Aria",
|
||||
Effect: "effect",
|
||||
NarrativeDescription: "narrative",
|
||||
SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 1, EndUnitID: 2}},
|
||||
Spell: "Cure Wounds",
|
||||
Caster: "Aria",
|
||||
SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 1, EndUnitID: 2}},
|
||||
}}}
|
||||
normalizer := newNormalizer(t)
|
||||
first, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
{
|
||||
"caster": " Aria ",
|
||||
"spell": " cure wounds ",
|
||||
"effect": "first effect",
|
||||
"narrative_description": "first narrative",
|
||||
"source_refs": [
|
||||
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2},
|
||||
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1},
|
||||
@@ -18,8 +16,6 @@
|
||||
{
|
||||
"caster": "aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "duplicate effect",
|
||||
"narrative_description": "duplicate narrative",
|
||||
"source_refs": [
|
||||
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1},
|
||||
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
|
||||
@@ -28,8 +24,6 @@
|
||||
{
|
||||
"caster": "aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "distinct effect",
|
||||
"narrative_description": "distinct narrative",
|
||||
"source_refs": [
|
||||
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
|
||||
]
|
||||
@@ -41,8 +35,6 @@
|
||||
{
|
||||
"caster": " Aria ",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "first effect",
|
||||
"narrative_description": "first narrative",
|
||||
"source_refs": [
|
||||
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1},
|
||||
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
|
||||
@@ -51,8 +43,6 @@
|
||||
{
|
||||
"caster": "aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "distinct effect",
|
||||
"narrative_description": "distinct narrative",
|
||||
"source_refs": [
|
||||
{"source_id": "source", "start_unit_id": 2, "end_unit_id": 2}
|
||||
]
|
||||
@@ -72,8 +62,6 @@
|
||||
{
|
||||
"caster": "Aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "heals an ally",
|
||||
"narrative_description": "Aria restores an ally's wounds.",
|
||||
"source_refs": [
|
||||
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1}
|
||||
]
|
||||
@@ -85,8 +73,6 @@
|
||||
{
|
||||
"caster": "Aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "heals an ally",
|
||||
"narrative_description": "Aria restores an ally's wounds.",
|
||||
"source_refs": [
|
||||
{"source_id": "source", "start_unit_id": 1, "end_unit_id": 1}
|
||||
]
|
||||
|
||||
@@ -29,24 +29,17 @@ const idPrefix = "npc:sha256:"
|
||||
type IssueCode string
|
||||
|
||||
const (
|
||||
IssueEmptyCanonicalName IssueCode = "empty_canonical_name"
|
||||
IssueEmptyAlias IssueCode = "empty_alias"
|
||||
IssueInvalidID IssueCode = "invalid_id"
|
||||
IssueIDMismatch IssueCode = "id_mismatch"
|
||||
IssueDuplicateCanonical IssueCode = "duplicate_canonical_identity"
|
||||
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"
|
||||
IssueEmptyCanonicalName IssueCode = "empty_canonical_name"
|
||||
IssueInvalidID IssueCode = "invalid_id"
|
||||
IssueIDMismatch IssueCode = "id_mismatch"
|
||||
IssueDuplicateCanonical IssueCode = "duplicate_canonical_identity"
|
||||
IssueDuplicateID IssueCode = "duplicate_id"
|
||||
)
|
||||
|
||||
// Issue is an inspectable identity validation problem. AliasIndex is -1 when
|
||||
// the issue applies to an NPC as a whole rather than a particular alias.
|
||||
// Issue is an inspectable identity validation problem.
|
||||
type Issue struct {
|
||||
Code IssueCode
|
||||
RecordIndex int
|
||||
AliasIndex int
|
||||
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
|
||||
// when the enclosing NPCList is more convenient at the call site.
|
||||
func ValidateRegistry(npcs []dnd.NPC) []Issue {
|
||||
type record struct {
|
||||
canonical string
|
||||
aliases []string
|
||||
}
|
||||
|
||||
records := make([]record, len(npcs))
|
||||
issues := make([]Issue, 0)
|
||||
canonicalOwners := make(map[string][]int)
|
||||
idOwners := make(map[string][]int)
|
||||
aliasOwners := make(map[string][]int)
|
||||
|
||||
for recordIndex, npc := range npcs {
|
||||
canonical := ComparisonKey(npc.Name)
|
||||
records[recordIndex].canonical = 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 {
|
||||
canonicalOwners[canonical] = append(canonicalOwners[canonical], recordIndex)
|
||||
}
|
||||
|
||||
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 {
|
||||
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 != "" {
|
||||
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 {
|
||||
if record.canonical != "" && len(canonicalOwners[record.canonical]) > 1 && canonicalOwners[record.canonical][0] != recordIndex {
|
||||
issues = append(issues, Issue{Code: IssueDuplicateCanonical, RecordIndex: recordIndex, AliasIndex: -1, Value: npcs[recordIndex].Name})
|
||||
for recordIndex, npc := range npcs {
|
||||
canonical := ComparisonKey(npc.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.
|
||||
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
|
||||
// structured fields for aggregate diagnostics.
|
||||
func (i Issue) Error() string {
|
||||
|
||||
@@ -77,18 +77,13 @@ func TestIdentityFunctionsAreSafeForConcurrentUse(t *testing.T) {
|
||||
func TestValidateRegistryReportsIdentityCollisionCategories(t *testing.T) {
|
||||
validID := DeriveID("Mira Thorn")
|
||||
npcs := []dnd.NPC{
|
||||
{ID: validID, Name: "Mira Thorn", Aliases: []string{"The Greencloak", "the greencloak", "Mira Thorn"}},
|
||||
{ID: validID, Name: "Mira Thorn", Aliases: []string{"The Greencloak"}},
|
||||
{ID: DeriveID("Captain Vale"), Name: "Captain Vale", Aliases: []string{"Mira Thorn"}},
|
||||
{ID: validID, Name: "Mira Thorn"},
|
||||
{ID: validID, Name: "Mira Thorn"},
|
||||
}
|
||||
issues := ValidateRegistry(npcs)
|
||||
want := map[IssueCode]bool{
|
||||
IssueDuplicateAlias: false,
|
||||
IssueOwnCanonicalAlias: false,
|
||||
IssueDuplicateCanonical: false,
|
||||
IssueDuplicateID: false,
|
||||
IssueAliasOwnershipCollision: false,
|
||||
IssueAliasCanonicalCollision: false,
|
||||
IssueDuplicateCanonical: false,
|
||||
IssueDuplicateID: false,
|
||||
}
|
||||
for _, issue := range issues {
|
||||
if _, ok := want[issue.Code]; ok {
|
||||
|
||||
@@ -5,6 +5,7 @@ package registry
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime"
|
||||
"strings"
|
||||
@@ -27,12 +28,13 @@ const (
|
||||
// Registry is an immutable, validated NPC registry prepared for prompt
|
||||
// grounding. All accessors return defensive copies.
|
||||
type Registry struct {
|
||||
bound bool
|
||||
list dnd.NPCList
|
||||
canonical []byte
|
||||
digest string
|
||||
promptInput contracts.LLMInputMaterial
|
||||
lookupByKey map[string]int
|
||||
bound bool
|
||||
list dnd.NPCList
|
||||
canonical []byte
|
||||
digest string
|
||||
projectionDigest string
|
||||
promptInput contracts.LLMInputMaterial
|
||||
lookupByKey map[string]int
|
||||
}
|
||||
|
||||
// Resolver retains only the validated construction-time registry and immutable
|
||||
@@ -141,11 +143,13 @@ func Resolve(references contracts.ReferenceSet) (*Registry, error) {
|
||||
slot, ok := references.Slots[ReferenceSlot]
|
||||
if !ok {
|
||||
content := []byte(emptyPrompt)
|
||||
projectionDigest := semanticDigest(content)
|
||||
return &Registry{
|
||||
list: dnd.NPCList{NPCs: []dnd.NPC{}},
|
||||
canonical: append([]byte(nil), content...),
|
||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, "", ""),
|
||||
lookupByKey: map[string]int{},
|
||||
list: dnd.NPCList{NPCs: []dnd.NPC{}},
|
||||
canonical: append([]byte(nil), content...),
|
||||
projectionDigest: projectionDigest,
|
||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
|
||||
lookupByKey: map[string]int{},
|
||||
}, nil
|
||||
}
|
||||
if len(slot.Items) != 1 {
|
||||
@@ -178,21 +182,24 @@ func Resolve(references contracts.ReferenceSet) (*Registry, error) {
|
||||
}
|
||||
|
||||
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 {
|
||||
lookupByKey[identity.ComparisonKey(npc.Name)] = index
|
||||
for _, alias := range npc.Aliases {
|
||||
lookupByKey[identity.ComparisonKey(alias)] = index
|
||||
}
|
||||
}
|
||||
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{
|
||||
bound: true,
|
||||
list: list,
|
||||
canonical: append([]byte(nil), content...),
|
||||
digest: digest,
|
||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, digest, ""),
|
||||
lookupByKey: lookupByKey,
|
||||
bound: true,
|
||||
list: list,
|
||||
canonical: append([]byte(nil), content...),
|
||||
digest: digest,
|
||||
projectionDigest: projectionDigest,
|
||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""),
|
||||
lookupByKey: lookupByKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -235,6 +242,15 @@ func (r *Registry) Digest() string {
|
||||
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.
|
||||
func (r *Registry) Count() int {
|
||||
if r == nil {
|
||||
@@ -243,8 +259,8 @@ func (r *Registry) Count() int {
|
||||
return len(r.list.NPCs)
|
||||
}
|
||||
|
||||
// PromptInput returns the canonical registry as a content-safe prompt input.
|
||||
// Reference provenance is deliberately omitted.
|
||||
// PromptInput returns the names-only registry projection as a content-safe
|
||||
// prompt input. Durable IDs, evidence, and reference provenance are omitted.
|
||||
func (r *Registry) PromptInput() contracts.LLMInputMaterial {
|
||||
if r == nil {
|
||||
return contracts.LLMInputMaterial{}
|
||||
@@ -252,8 +268,8 @@ func (r *Registry) PromptInput() contracts.LLMInputMaterial {
|
||||
return r.promptInput.Clone()
|
||||
}
|
||||
|
||||
// Lookup returns the canonical NPC for an exact canonical-name or alias match
|
||||
// under the NPC identity comparison policy.
|
||||
// Lookup returns the canonical NPC for an exact canonical-name match under the
|
||||
// NPC identity comparison policy.
|
||||
func (r *Registry) Lookup(value string) (dnd.NPC, bool) {
|
||||
if r == nil {
|
||||
return dnd.NPC{}, false
|
||||
@@ -270,13 +286,26 @@ func semanticDigest(content []byte) string {
|
||||
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 {
|
||||
parts := make([]string, len(issues))
|
||||
for index, issue := range issues {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
value.Aliases = append([]string(nil), value.Aliases...)
|
||||
value.Relationships = append([]dnd.NPCRelationship(nil), value.Relationships...)
|
||||
value.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -2,313 +2,161 @@ package registry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
)
|
||||
|
||||
func TestResolveAbsentRegistryUsesExactEmptyPrompt(t *testing.T) {
|
||||
resolved, err := Resolve(contracts.ReferenceSet{})
|
||||
func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) {
|
||||
registry, err := Resolve(contracts.ReferenceSet{})
|
||||
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 {
|
||||
t.Fatalf("resolved unbound registry = %#v, want no semantic metadata", resolved)
|
||||
input := registry.PromptInput()
|
||||
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 input.Name != ReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != "" || input.OriginURI != "" {
|
||||
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)
|
||||
if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" {
|
||||
t.Fatalf("projection digest/input = %q/%#v", registry.ProjectionDigest(), input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCanonicalizesAndProvidesSemanticIdentity(t *testing.T) {
|
||||
value := validRegistryList()
|
||||
canonical := encodeRegistry(t, value)
|
||||
raw := append([]byte(" \n"), canonical...)
|
||||
raw = append(raw, []byte("\n ")...)
|
||||
|
||||
resolved, err := Resolve(registryReference(raw, "file:///another-session/npcs.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||
func TestResolveKeepsDurableProvenanceAndProjectsOnlyOrderedNames(t *testing.T) {
|
||||
list := registryFixture()
|
||||
registry := resolveList(t, list)
|
||||
if !registry.Bound() || registry.Digest() == "" || registry.Count() != 2 {
|
||||
t.Fatalf("registry identity = bound %t digest %q count %d", registry.Bound(), registry.Digest(), registry.Count())
|
||||
}
|
||||
if !resolved.Bound() || resolved.Count() != len(value.NPCs) {
|
||||
t.Fatalf("resolved registry = %#v, want bound registry with %d NPC", resolved, len(value.NPCs))
|
||||
if got := string(registry.PromptInput().Content); got != `{"npcs":[{"name":"Mira Thorn"},{"name":"Captain Vale"}]}` {
|
||||
t.Fatalf("prompt projection = %s", got)
|
||||
}
|
||||
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}},
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
if registry.PromptInput().Digest != registry.ProjectionDigest() || registry.Digest() == registry.ProjectionDigest() {
|
||||
t.Fatalf("full/projection digests = %q/%q", registry.Digest(), registry.ProjectionDigest())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryAccessorsAndLookupAreDefensive(t *testing.T) {
|
||||
resolved, err := Resolve(registryReference(encodeRegistry(t, validRegistryList()), "file:///npc-registry.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 {
|
||||
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
changedBytes, err := nameProjection(evidenceChanged)
|
||||
if err != nil {
|
||||
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].Aliases[0] = "changed alias"
|
||||
npcs[0].Relationships[0].Target = "changed target"
|
||||
npcs[0].SourceRefs[0].SourceID = "changed source"
|
||||
if got, ok := resolved.Lookup("The Greencloak"); !ok || got.Name != "Mira Thorn" {
|
||||
t.Fatalf("Lookup() after NPC mutation = %#v, %v, want original NPC", got, ok)
|
||||
npcs[0].SourceRefs[0].SourceID = "changed"
|
||||
content := registry.CanonicalBytes()
|
||||
content[0] = '['
|
||||
input := registry.PromptInput()
|
||||
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)
|
||||
}
|
||||
|
||||
wantCanonical := string(resolved.CanonicalBytes())
|
||||
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")
|
||||
if registry.CanonicalBytes()[0] != '{' || registry.PromptInput().Content[0] != '{' {
|
||||
t.Fatal("registry bytes mutated through accessor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverUsesSeededFallbackAndCachesGeneratedCanonicalRegistry(t *testing.T) {
|
||||
staticContent := encodeRegistry(t, validRegistryList())
|
||||
resolver, err := NewResolver(registryReference(staticContent, "file:///static.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewResolver() error = %v", err)
|
||||
}
|
||||
if got, err := resolver.Resolve(contracts.ReferenceSet{}); err != nil || got != resolver.Seeded() {
|
||||
t.Fatalf("Resolve(absent) = %p, %v, want seeded %p", got, 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) {
|
||||
content := encodeRegistry(t, validRegistryList())
|
||||
resolver, err := NewResolver(contracts.ReferenceSet{})
|
||||
if err != nil {
|
||||
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 TestResolveRejectsMalformedOrUnsupportedRegistryInput(t *testing.T) {
|
||||
for _, item := range []contracts.ReferenceItem{
|
||||
{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 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)
|
||||
func TestResolverReusesEquivalentCanonicalRegistries(t *testing.T) {
|
||||
set := listReferenceSet(t, registryFixture())
|
||||
resolver, err := NewResolver(set)
|
||||
if err != nil {
|
||||
t.Fatalf("encode NPC registry: %v", err)
|
||||
t.Fatal(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)
|
||||
resolved, err := resolver.Resolve(set)
|
||||
if err != nil || resolved != resolver.Seeded() {
|
||||
t.Fatalf("Resolve() = %p, %v; seeded %p", resolved, err, resolver.Seeded())
|
||||
}
|
||||
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 registryFixture() dnd.NPCList {
|
||||
return dnd.NPCList{NPCs: []dnd.NPC{
|
||||
{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}},
|
||||
{ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
|
||||
}}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,10 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
|
||||
Module: spellextract.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(spellshape.Key),
|
||||
pipeline.Binding(spellcatalog.Key),
|
||||
pipeline.Binding(spellsourcerefs.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(spellrelatedness.Key),
|
||||
},
|
||||
})
|
||||
@@ -46,10 +46,10 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
|
||||
Module: spellnormalize.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(spellshape.Key),
|
||||
pipeline.Binding(spellcatalog.Key),
|
||||
pipeline.Binding(spellsourcerefs.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(spellrelatedness.Key),
|
||||
},
|
||||
})
|
||||
@@ -60,9 +60,9 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
|
||||
Module: npcextract.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(npcshape.Key),
|
||||
pipeline.Binding(npcsourcerefs.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(npcrelatedness.Key),
|
||||
},
|
||||
})
|
||||
@@ -73,10 +73,10 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
|
||||
Module: npcnormalize.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(npcshape.Key),
|
||||
pipeline.Binding(npcidentity.Key),
|
||||
pipeline.Binding(npcsourcerefs.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(npcrelatedness.Key),
|
||||
},
|
||||
})
|
||||
@@ -87,9 +87,9 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
|
||||
Module: combatextract.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(combatshape.Key),
|
||||
pipeline.Binding(combatsourcerefs.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(combatrelatedness.Key),
|
||||
},
|
||||
})
|
||||
@@ -100,10 +100,10 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
|
||||
Module: combatnormalize.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(combatshape.Key),
|
||||
pipeline.Binding(combatinvariants.Key),
|
||||
pipeline.Binding(combatsourcerefs.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(combatrelatedness.Key),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -59,23 +59,6 @@ func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, err
|
||||
|
||||
func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
|
||||
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 {
|
||||
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
|
||||
}
|
||||
|
||||
@@ -52,10 +52,10 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
|
||||
})
|
||||
wantChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/spells/shape"),
|
||||
pipeline.Binding("extract/dnd/spells/catalog"),
|
||||
pipeline.Binding("extract/dnd/spells/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/spells/source_relatedness"),
|
||||
}
|
||||
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{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/npcs/shape"),
|
||||
pipeline.Binding("extract/dnd/npcs/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/npcs/source_relatedness"),
|
||||
}
|
||||
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{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/npcs/shape"),
|
||||
pipeline.Binding("normalize/dnd/npcs/identity"),
|
||||
pipeline.Binding("extract/dnd/npcs/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/npcs/source_relatedness"),
|
||||
}
|
||||
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{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||
}
|
||||
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{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||
pipeline.Binding("normalize/dnd/combat-turns/invariants"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||
}
|
||||
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) {
|
||||
round := 1
|
||||
resolution := "hit"
|
||||
targets := []string{"Mira"}
|
||||
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}
|
||||
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"}}},
|
||||
}
|
||||
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" {
|
||||
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")
|
||||
}
|
||||
tests := []struct {
|
||||
|
||||
@@ -28,7 +28,6 @@ var sharedPromptPaths = map[string]string{
|
||||
"common-dnd-identity.md": "assets/prompts/common-dnd-identity.md",
|
||||
"common-dnd-transcript.md": "assets/prompts/common-dnd-transcript.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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -17,11 +17,9 @@ type SpellList struct {
|
||||
}
|
||||
|
||||
type SpellCast struct {
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
Effect string `json:"effect"`
|
||||
NarrativeDescription string `json:"narrative_description"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
}
|
||||
|
||||
type NPCList struct {
|
||||
@@ -29,17 +27,9 @@ type NPCList struct {
|
||||
}
|
||||
|
||||
type NPC struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Aliases []string `json:"aliases"`
|
||||
Description string `json:"description"`
|
||||
Relationships []NPCRelationship `json:"relationships"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
}
|
||||
|
||||
type NPCRelationship struct {
|
||||
Target string `json:"target"`
|
||||
Relationship string `json:"relationship"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
}
|
||||
|
||||
type CombatTurnList struct {
|
||||
@@ -59,28 +49,5 @@ const (
|
||||
type CombatTurn struct {
|
||||
Actor string `json:"actor"`
|
||||
TurnKind CombatTurnKind `json:"turn_kind"`
|
||||
Round *int `json:"round"`
|
||||
Actions []CombatAction `json:"actions"`
|
||||
Summary string `json:"summary"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -66,31 +66,6 @@ func issuesFor(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
|
||||
if turn.Actor != identity.NormalizeDisplay(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++ {
|
||||
previous := turn.SourceRefs[refIndex-1]
|
||||
current := turn.SourceRefs[refIndex]
|
||||
@@ -169,12 +144,6 @@ func duplicateKey(turn dnd.CombatTurn) (string, bool) {
|
||||
var key strings.Builder
|
||||
writeKeyString(&key, identity.ComparisonKey(turn.Actor))
|
||||
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 {
|
||||
writeKeyString(&key, ref.SourceID)
|
||||
writeKeyInt(&key, ref.StartUnitID)
|
||||
|
||||
@@ -27,22 +27,6 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
|
||||
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: "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) {
|
||||
value.CombatTurns[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
||||
}, 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]}
|
||||
}, want: "out of chronological order"},
|
||||
{name: "duplicate identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) {
|
||||
duplicate := value.CombatTurns[0]
|
||||
duplicate.Summary = "different prose"
|
||||
value.CombatTurns = append(value.CombatTurns, duplicate)
|
||||
value.CombatTurns = append(value.CombatTurns, value.CombatTurns[0])
|
||||
}, want: "duplicates combat turn"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
@@ -121,11 +103,9 @@ func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
|
||||
}
|
||||
|
||||
func normalizedList() dnd.CombatTurnList {
|
||||
resolution := "hit"
|
||||
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Round: intPointer(1),
|
||||
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{"Goblin"}, Resolution: &resolution}},
|
||||
Summary: "Aria attacks", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}},
|
||||
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
|
||||
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}}}
|
||||
}
|
||||
|
||||
func intPointer(value int) *int { return &value }
|
||||
|
||||
func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn {
|
||||
turn.SourceRefs = []source.SourceRef{ref}
|
||||
return turn
|
||||
|
||||
@@ -60,39 +60,9 @@ func issuesFor(value dnd.CombatTurnList) []string {
|
||||
if !validTurnKind(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 {
|
||||
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
|
||||
}
|
||||
@@ -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 {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
}
|
||||
|
||||
@@ -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: "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: "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: "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 {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -85,11 +74,8 @@ func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
|
||||
}
|
||||
|
||||
func validCombatTurnList() dnd.CombatTurnList {
|
||||
round := 2
|
||||
resolution := "The goblin is wounded."
|
||||
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Round: &round,
|
||||
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{}, Resolution: &resolution}},
|
||||
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -87,11 +87,9 @@ func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
|
||||
}
|
||||
|
||||
func validCombatTurnList() dnd.CombatTurnList {
|
||||
resolution := "The goblin is wounded."
|
||||
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
|
||||
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{}, Resolution: &resolution}},
|
||||
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package sourcerelatedness
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"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)
|
||||
for turnIndex, turn := range req.Value.CombatTurns {
|
||||
citedText := citedTexts[turnIndex]
|
||||
issues := make([]string, 0)
|
||||
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 {
|
||||
if actorAppearsInCitedText(citedText, turn.Actor) {
|
||||
continue
|
||||
}
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: fmt.Sprintf("combat_turns[%d]", turnIndex),
|
||||
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
|
||||
@@ -74,27 +66,6 @@ func actorAppearsInCitedText(citedText string, actor string) bool {
|
||||
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 {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,10 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
|
||||
func TestValidatorUsesDocumentOrderAndUnicodeComparisonForActorAndDeclaration(t *testing.T) {
|
||||
resolution := "The goblin is hit."
|
||||
func TestValidatorUsesDocumentOrderAndUnicodeComparisonForActor(t *testing.T) {
|
||||
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "O'Rin Thorn", TurnKind: dnd.CombatTurnKindTurn,
|
||||
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "O'Rin attacks", Targets: []string{"unmentioned target"}, Resolution: &resolution}},
|
||||
Summary: "O'Rin attacks.", SourceRefs: []source.SourceRef{
|
||||
SourceRefs: []source.SourceRef{
|
||||
{SourceID: "session", StartUnitID: 2, 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{{
|
||||
Actor: "Missing\nName", TurnKind: dnd.CombatTurnKindReaction,
|
||||
Actions: []dnd.CombatAction{
|
||||
{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}},
|
||||
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})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 1 {
|
||||
t.Fatalf("Validate() = %#v, %v; want one warning for the turn", result, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorDoesNotMatchShortActorSubstring(t *testing.T) {
|
||||
resolution := "The cart is struck."
|
||||
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Art", TurnKind: dnd.CombatTurnKindTurn,
|
||||
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "cart attacks", Targets: []string{}, Resolution: &resolution}},
|
||||
Summary: "The cart attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
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."}}}
|
||||
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 {
|
||||
return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
|
||||
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{"absent target"}, Resolution: nil}},
|
||||
Summary: "Aria attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,9 +48,6 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
issues := make([]string, len(identityIssues))
|
||||
for index, issue := range identityIssues {
|
||||
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))
|
||||
}
|
||||
return contracts.ValidationResult{
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestValidatorContractAndRegistration(t *testing.T) {
|
||||
|
||||
func TestValidatorDefersShapeAndRejectsIdentityIssues(t *testing.T) {
|
||||
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))
|
||||
if err != nil || !result.Approved {
|
||||
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.NPCs[0].ID = "not-an-id"
|
||||
value.NPCs[1].Aliases = []string{"Shared Alias"}
|
||||
value.NPCs[0].Aliases = []string{"Shared Alias"}
|
||||
value.NPCs[1].Name = value.NPCs[0].Name
|
||||
value.NPCs[1].ID = value.NPCs[0].ID
|
||||
result, err = validator.Validate(context.Background(), validationRequest(value))
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
|
||||
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) {
|
||||
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)}
|
||||
for index := range value.NPCs {
|
||||
name := fmt.Sprintf("NPC %d", index)
|
||||
value.NPCs[index] = dnd.NPC{
|
||||
ID: "npc:sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
Name: name,
|
||||
Aliases: []string{},
|
||||
Description: "description",
|
||||
Relationships: []dnd.NPCRelationship{},
|
||||
SourceRefs: []source.SourceRef{sourceRefForTest()},
|
||||
}
|
||||
value.NPCs[index] = dnd.NPC{ID: "npc:sha256:0000000000000000000000000000000000000000000000000000000000000000", Name: name, SourceRefs: []source.SourceRef{sourceRefForTest()}}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -61,31 +61,6 @@ func issuesFor(value dnd.NPCList) []string {
|
||||
if strings.TrimSpace(npc.Name) == "" {
|
||||
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 {
|
||||
issues = append(issues, prefix+".source_refs must not be empty")
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@ func TestValidatorApprovesWellFormedNPCPayload(t *testing.T) {
|
||||
|
||||
func TestValidatorRejectsRequiredShapeValues(t *testing.T) {
|
||||
value := validNPCList()
|
||||
value.NPCs[0].Aliases = nil
|
||||
value.NPCs[0].Name = ""
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -38,13 +38,13 @@ func TestValidatorBoundsDiagnosticsAndQuotesUnicode(t *testing.T) {
|
||||
value := dnd.NPCList{NPCs: make([]dnd.NPC, 24)}
|
||||
long := strings.Repeat("火", 220) + "\n\t"
|
||||
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))
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func TestValidatorDoesNotMutateValue(t *testing.T) {
|
||||
value := validNPCList()
|
||||
before := 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)
|
||||
}
|
||||
}
|
||||
@@ -80,9 +80,8 @@ func requestWithValue(value dnd.NPCList) contracts.TypedValidationRequest[dnd.NP
|
||||
|
||||
func validNPCList() dnd.NPCList {
|
||||
return dnd.NPCList{NPCs: []dnd.NPC{{
|
||||
ID: "candidate", Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A guarded ranger.",
|
||||
Relationships: []dnd.NPCRelationship{{Target: "Captain Vale", Relationship: "reports to"}},
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
ID: "candidate", Name: "Mira Thorn",
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,5 +84,5 @@ func validDocument() *source.SourceDocument {
|
||||
}
|
||||
|
||||
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}}}}}
|
||||
}
|
||||
|
||||
@@ -60,15 +60,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
}
|
||||
|
||||
func npcAppearsInCitedText(citedText string, npc dnd.NPC) bool {
|
||||
if shared.ContainsTokenSequence(citedText, npc.Name) {
|
||||
return true
|
||||
}
|
||||
for _, alias := range npc.Aliases {
|
||||
if shared.ContainsTokenSequence(citedText, alias) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return shared.ContainsTokenSequence(citedText, npc.Name)
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
|
||||
@@ -12,14 +12,14 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
|
||||
func TestValidatorMatchesCanonicalNamesAndAliasesWithUnicodeVariants(t *testing.T) {
|
||||
func TestValidatorMatchesCanonicalNamesWithUnicodeVariants(t *testing.T) {
|
||||
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: 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{
|
||||
{ID: 1, Kind: "message", Text: " o’rin\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})
|
||||
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) {
|
||||
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})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 1 {
|
||||
@@ -47,7 +47,7 @@ func TestValidatorWarnsAtMostOncePerNPCForUnrelatedCitations(t *testing.T) {
|
||||
|
||||
func TestValidatorDoesNotMatchShortNameSubstring(t *testing.T) {
|
||||
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."}}}
|
||||
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 {
|
||||
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})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||
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) {
|
||||
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")}}}}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), References: references, Value: value})
|
||||
if err != nil || len(result.Warnings) != 1 {
|
||||
|
||||
@@ -201,11 +201,9 @@ func spellList(names ...string) dnd.SpellList {
|
||||
|
||||
func validCast(name string) dnd.SpellCast {
|
||||
return dnd.SpellCast{
|
||||
Caster: "Aria",
|
||||
Spell: name,
|
||||
Effect: "heals an ally",
|
||||
NarrativeDescription: "Aria restores Borin.",
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
Caster: "Aria",
|
||||
Spell: name,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,12 +48,6 @@ func Validate(value dnd.SpellList) error {
|
||||
if strings.TrimSpace(spell.Spell) == "" {
|
||||
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 {
|
||||
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
Reference in New Issue
Block a user