Compare commits

...

9 Commits

108 changed files with 2449 additions and 3223 deletions

View File

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

View File

@@ -358,7 +358,7 @@ production validators do not call the LLM and must not set `llm_profile`.
| merge | `appendorder` | Combines typed artifacts in chunk order. |
| 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

View File

@@ -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.

View File

@@ -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.

View File

@@ -18,7 +18,9 @@ The durable JSON Schema is owned by the D&D spell artifact codec. The
extractor's private LLM response schema is a separate transport contract: its
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.

View File

@@ -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

View File

@@ -29,6 +29,12 @@ variants. The D&D production registrar registers the canonical typed spell,
NPC, and combat implementations, including their kind-specific merge and
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

View File

@@ -87,7 +87,7 @@ Configuration. The implemented module packages are:
| `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. |
| `internal/modules/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.

View File

@@ -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,

View File

@@ -57,14 +57,14 @@ records the decision, and stops without executing the producer or consumer.
The loader assigns a typed category and reason code at each validation site;
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.

View File

@@ -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

View File

@@ -1,174 +1,373 @@
# D&D Validation-Boundary Alignment Implementation Plan
# Minimal D&D Extraction Contracts Implementation Plan
Status: Proposed.
**Status:** Implemented
Implement this plan in order. It repairs the immediate combat extraction
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.

View File

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

View File

@@ -57,8 +57,8 @@ func TestAssembledSpellPipelineNormalizesMergedCasts(t *testing.T) {
t.Fatalf("normalized casts = %#v, want collapsed duplicate plus distinct evidence", normalized.SpellCasts)
}
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)

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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: {}},

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

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

View File

@@ -13,9 +13,6 @@
"required": [
"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,

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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}]
}
]

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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:

View File

@@ -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.

View File

@@ -2,18 +2,17 @@ Extract Dungeons & Dragons combat-turn artifacts from the supplied transcript.
Include a record only when the transcript establishes that an in-world
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.

View File

@@ -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": {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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])
}
}

View File

@@ -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}},
},
},

View File

@@ -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",
},
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"
}
}
}

View File

@@ -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

View File

@@ -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) }

View File

@@ -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"`
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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:

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"
}
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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"`
}

View File

@@ -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}},
}}}
}

View File

@@ -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},
},

View File

@@ -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",
},
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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: " yle ", 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: " yle ", 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))

View File

@@ -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}
]

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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),
},
})

View File

@@ -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...)
}

View File

@@ -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 {

View File

@@ -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",
}

View File

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

View File

@@ -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"`
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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}
}

View File

@@ -28,18 +28,7 @@ func TestValidateRejectsEveryOwnedShapeBoundary(t *testing.T) {
{name: "missing combat turns", mutate: func(value *dnd.CombatTurnList) { value.CombatTurns = nil }, want: "combat_turns must be present"},
{name: "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}},
}}}
}

View File

@@ -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}},
}}}
}

View File

@@ -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}
}

View File

@@ -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}},
}}}
}

View File

@@ -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{

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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}},
}}}
}

View File

@@ -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}}}}}
}

View File

@@ -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 {

View File

@@ -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: " orin\u2003thorn appears."},
@@ -27,13 +27,13 @@ func TestValidatorMatchesCanonicalNamesAndAliasesWithUnicodeVariants(t *testing.
}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value})
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 {

View File

@@ -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}},
}
}

View File

@@ -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