diff --git a/docs/config.md b/docs/config.md index 56b2ce9..8452b6c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -380,8 +380,8 @@ production validators do not call the LLM and must not set `llm_profile`. | `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: @@ -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 diff --git a/docs/integrations/dnd-combat-turn-artifacts.md b/docs/integrations/dnd-combat-turn-artifacts.md index 25b38e7..9a6456d 100644 --- a/docs/integrations/dnd-combat-turn-artifacts.md +++ b/docs/integrations/dnd-combat-turn-artifacts.md @@ -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,16 +100,14 @@ 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 @@ -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,8 +137,8 @@ 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`. diff --git a/docs/internal/llm.md b/docs/internal/llm.md index 3c71937..0b9e089 100644 --- a/docs/internal/llm.md +++ b/docs/internal/llm.md @@ -135,8 +135,8 @@ 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 the NPC registry, -catalog, task, instructions, and transcript. Combat renders -immediate resolution, NPC registry, task, instructions, and transcript. The +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. diff --git a/docs/internal/modules.md b/docs/internal/modules.md index 3ba0336..32f9b33 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -265,8 +265,8 @@ 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 @@ -333,8 +333,8 @@ 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 +immutable view during runtime. It display-normalizes actors, +rewrites exact canonical-name or alias 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 @@ -414,16 +414,14 @@ 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 diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 6e62d9e..26fb807 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -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, 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. | diff --git a/internal/modules/dnd/codec/combatturns/assets/schemas/dnd_combat_turns.v1.json b/internal/modules/dnd/codec/combatturns/assets/schemas/dnd_combat_turns.v1.json index b7aa233..6b7ebcb 100644 --- a/internal/modules/dnd/codec/combatturns/assets/schemas/dnd_combat_turns.v1.json +++ b/internal/modules/dnd/codec/combatturns/assets/schemas/dnd_combat_turns.v1.json @@ -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, diff --git a/internal/modules/dnd/codec/combatturns/codec.go b/internal/modules/dnd/codec/combatturns/codec.go index 01342f2..df2f5dd 100644 --- a/internal/modules/dnd/codec/combatturns/codec.go +++ b/internal/modules/dnd/codec/combatturns/codec.go @@ -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 - } -} diff --git a/internal/modules/dnd/codec/combatturns/codec_test.go b/internal/modules/dnd/codec/combatturns/codec_test.go index 2d95d92..c42aabe 100644 --- a/internal/modules/dnd/codec/combatturns/codec_test.go +++ b/internal/modules/dnd/codec/combatturns/codec_test.go @@ -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) } } diff --git a/internal/modules/dnd/codec/combatturns/testdata/dnd_combat_turns.v1.json b/internal/modules/dnd/codec/combatturns/testdata/dnd_combat_turns.v1.json index 5ffdd8f..4d559c3 100644 --- a/internal/modules/dnd/codec/combatturns/testdata/dnd_combat_turns.v1.json +++ b/internal/modules/dnd/codec/combatturns/testdata/dnd_combat_turns.v1.json @@ -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}]}]} diff --git a/internal/modules/dnd/extract/combatturns/assets/prompts/dnd.combat_turns.yaml b/internal/modules/dnd/extract/combatturns/assets/prompts/dnd.combat_turns.yaml index 388f62a..c46c519 100644 --- a/internal/modules/dnd/extract/combatturns/assets/prompts/dnd.combat_turns.yaml +++ b/internal/modules/dnd/extract/combatturns/assets/prompts/dnd.combat_turns.yaml @@ -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: diff --git a/internal/modules/dnd/extract/combatturns/assets/prompts/instructions.md b/internal/modules/dnd/extract/combatturns/assets/prompts/instructions.md index 3ec6129..adf55c2 100644 --- a/internal/modules/dnd/extract/combatturns/assets/prompts/instructions.md +++ b/internal/modules/dnd/extract/combatturns/assets/prompts/instructions.md @@ -1,7 +1,6 @@ Return the combat_turns array even when no combat turn is established. Return -one or more actions for every turn. For turn_kind, use exactly one of: turn, -reaction, legendary_action, lair_action, or other. For each action category, -use exactly one of: attack, spell, movement, item, ability_check, saving_throw, -condition, or other. 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. diff --git a/internal/modules/dnd/extract/combatturns/assets/prompts/task.md b/internal/modules/dnd/extract/combatturns/assets/prompts/task.md index 76445ef..3125037 100644 --- a/internal/modules/dnd/extract/combatturns/assets/prompts/task.md +++ b/internal/modules/dnd/extract/combatturns/assets/prompts/task.md @@ -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. diff --git a/internal/modules/dnd/extract/combatturns/assets/schemas/dnd_combat_turns_llm.v1.json b/internal/modules/dnd/extract/combatturns/assets/schemas/dnd_combat_turns_llm.v1.json index c323d37..f3e8af9 100644 --- a/internal/modules/dnd/extract/combatturns/assets/schemas/dnd_combat_turns_llm.v1.json +++ b/internal/modules/dnd/extract/combatturns/assets/schemas/dnd_combat_turns_llm.v1.json @@ -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": { diff --git a/internal/modules/dnd/extract/combatturns/canonicalize.go b/internal/modules/dnd/extract/combatturns/canonicalize.go index 1446772..ec90982 100644 --- a/internal/modules/dnd/extract/combatturns/canonicalize.go +++ b/internal/modules/dnd/extract/combatturns/canonicalize.go @@ -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 -} diff --git a/internal/modules/dnd/extract/combatturns/extractor.go b/internal/modules/dnd/extract/combatturns/extractor.go index ccfc8ab..af99573 100644 --- a/internal/modules/dnd/extract/combatturns/extractor.go +++ b/internal/modules/dnd/extract/combatturns/extractor.go @@ -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, diff --git a/internal/modules/dnd/extract/combatturns/extractor_test.go b/internal/modules/dnd/extract/combatturns/extractor_test.go index 081a28e..d37ff80 100644 --- a/internal/modules/dnd/extract/combatturns/extractor_test.go +++ b/internal/modules/dnd/extract/combatturns/extractor_test.go @@ -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]) } diff --git a/internal/modules/dnd/extract/combatturns/model.go b/internal/modules/dnd/extract/combatturns/model.go index 3b59378..9eb3003 100644 --- a/internal/modules/dnd/extract/combatturns/model.go +++ b/internal/modules/dnd/extract/combatturns/model.go @@ -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 -} diff --git a/internal/modules/dnd/extract/combatturns/model_test.go b/internal/modules/dnd/extract/combatturns/model_test.go index 1973b89..a43884a 100644 --- a/internal/modules/dnd/extract/combatturns/model_test.go +++ b/internal/modules/dnd/extract/combatturns/model_test.go @@ -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]) } } diff --git a/internal/modules/dnd/extract/combatturns/schema_test.go b/internal/modules/dnd/extract/combatturns/schema_test.go index ea3fd36..568a5e4 100644 --- a/internal/modules/dnd/extract/combatturns/schema_test.go +++ b/internal/modules/dnd/extract/combatturns/schema_test.go @@ -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}}, }, }, diff --git a/internal/modules/dnd/extract/combatturns/scriptorium_assets.go b/internal/modules/dnd/extract/combatturns/scriptorium_assets.go index 4ed03b6..d9b00bb 100644 --- a/internal/modules/dnd/extract/combatturns/scriptorium_assets.go +++ b/internal/modules/dnd/extract/combatturns/scriptorium_assets.go @@ -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", }, } diff --git a/internal/modules/dnd/normalize/combatturns/normalizer.go b/internal/modules/dnd/normalize/combatturns/normalizer.go index c844472..3e31e59 100644 --- a/internal/modules/dnd/normalize/combatturns/normalizer.go +++ b/internal/modules/dnd/normalize/combatturns/normalizer.go @@ -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" @@ -122,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) { @@ -137,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, @@ -145,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), @@ -160,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), @@ -205,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) @@ -293,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 @@ -454,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) @@ -499,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, diff --git a/internal/modules/dnd/normalize/combatturns/normalizer_test.go b/internal/modules/dnd/normalize/combatturns/normalizer_test.go index 40658b0..0eeaae2 100644 --- a/internal/modules/dnd/normalize/combatturns/normalizer_test.go +++ b/internal/modules/dnd/normalize/combatturns/normalizer_test.go @@ -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: " aria ", - TurnKind: dnd.CombatTurnKindTurn, - Actions: []dnd.CombatAction{{ - Category: dnd.CombatActionCategoryAttack, - Declaration: " attacks\n with a sword ", - Targets: []string{" goblin ", "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") } } @@ -79,8 +68,6 @@ func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ Actor: "aria", TurnKind: dnd.CombatTurnKindTurn, - Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "attacks", Targets: []string{"goblin"}}}, - Summary: "Aria 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})}, } @@ -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}, } } @@ -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 } diff --git a/internal/modules/dnd/register/merge.go b/internal/modules/dnd/register/merge.go index e9265ae..b832c34 100644 --- a/internal/modules/dnd/register/merge.go +++ b/internal/modules/dnd/register/merge.go @@ -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...) } diff --git a/internal/modules/dnd/register/register_test.go b/internal/modules/dnd/register/register_test.go index 2701b66..d5199a4 100644 --- a/internal/modules/dnd/register/register_test.go +++ b/internal/modules/dnd/register/register_test.go @@ -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 { diff --git a/internal/modules/dnd/shared/assets.go b/internal/modules/dnd/shared/assets.go index a751eff..91b0c7c 100644 --- a/internal/modules/dnd/shared/assets.go +++ b/internal/modules/dnd/shared/assets.go @@ -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", } diff --git a/internal/modules/dnd/shared/assets/prompts/common-dnd-immediate-resolution.md b/internal/modules/dnd/shared/assets/prompts/common-dnd-immediate-resolution.md deleted file mode 100644 index 89c105e..0000000 --- a/internal/modules/dnd/shared/assets/prompts/common-dnd-immediate-resolution.md +++ /dev/null @@ -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. diff --git a/internal/modules/dnd/types.go b/internal/modules/dnd/types.go index 64b15da..91b3828 100644 --- a/internal/modules/dnd/types.go +++ b/internal/modules/dnd/types.go @@ -49,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"` -} diff --git a/internal/modules/dnd/validate/combatturns/invariants/validator.go b/internal/modules/dnd/validate/combatturns/invariants/validator.go index d797e12..d7f27e6 100644 --- a/internal/modules/dnd/validate/combatturns/invariants/validator.go +++ b/internal/modules/dnd/validate/combatturns/invariants/validator.go @@ -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) diff --git a/internal/modules/dnd/validate/combatturns/invariants/validator_test.go b/internal/modules/dnd/validate/combatturns/invariants/validator_test.go index 19ab7cf..8fc9658 100644 --- a/internal/modules/dnd/validate/combatturns/invariants/validator_test.go +++ b/internal/modules/dnd/validate/combatturns/invariants/validator_test.go @@ -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 diff --git a/internal/modules/dnd/validate/combatturns/shape/validator.go b/internal/modules/dnd/validate/combatturns/shape/validator.go index c421bdb..84b911b 100644 --- a/internal/modules/dnd/validate/combatturns/shape/validator.go +++ b/internal/modules/dnd/validate/combatturns/shape/validator.go @@ -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} } diff --git a/internal/modules/dnd/validate/combatturns/shape/validator_test.go b/internal/modules/dnd/validate/combatturns/shape/validator_test.go index 3703015..446bf6a 100644 --- a/internal/modules/dnd/validate/combatturns/shape/validator_test.go +++ b/internal/modules/dnd/validate/combatturns/shape/validator_test.go @@ -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}}, }}} } diff --git a/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go b/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go index 24d7d2b..25674c4 100644 --- a/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go +++ b/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go @@ -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}}, }}} } diff --git a/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go b/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go index 0f5fe15..e9848a9 100644 --- a/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go @@ -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} } diff --git a/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go b/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go index f4c3007..41edc1a 100644 --- a/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go @@ -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}}, }}} } diff --git a/internal/modules/integration/dnd_combat_runner_test.go b/internal/modules/integration/dnd_combat_runner_test.go index a972470..a128427 100644 --- a/internal/modules/integration/dnd_combat_runner_test.go +++ b/internal/modules/integration/dnd_combat_runner_test.go @@ -48,10 +48,10 @@ func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing } client := &fakeCombatLLMClient{responses: []string{ - combatTestInvalidEnumResponse("unsupported", "attack"), - combatTestTurnResponse("mira thorn", "turn", "watches", "mira thorn", 1, 1), - combatTestTurnResponse("Mira Thorn", "reaction", "asks", "Hooded Guard", 2, 2), - combatTestTurnResponse("Hooded Guard", "turn", "attacks", "mira thorn", 3, 3), + combatTestInvalidEnumResponse("unsupported"), + combatTestTurnResponse("mira thorn", "turn", 1), + combatTestTurnResponse("Mira Thorn", "reaction", 2), + combatTestTurnResponse("Hooded Guard", "turn", 3), }} prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client}) if err != nil { @@ -86,8 +86,8 @@ func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing if err != nil { t.Fatalf("Decode(combat output) error = %v", err) } - if len(value.CombatTurns) != 3 || value.CombatTurns[0].Actor != "Mira Thorn" || value.CombatTurns[0].Actions[0].Targets[0] != "Mira Thorn" || value.CombatTurns[1].Actor != "Mira Thorn" || value.CombatTurns[2].Actor != "Hooded Guard" { - t.Fatalf("normalized combat output = %#v, want ordered canonical actors and targets", value) + if len(value.CombatTurns) != 3 || value.CombatTurns[0].Actor != "Mira Thorn" || value.CombatTurns[1].Actor != "Mira Thorn" || value.CombatTurns[2].Actor != "Hooded Guard" { + t.Fatalf("normalized combat output = %#v, want ordered canonical actors", value) } for _, turn := range value.CombatTurns { for _, ref := range turn.SourceRefs { @@ -96,8 +96,8 @@ func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing } } } - if !hasCombatWarning(output.Warnings, "combat_turn_not_near_source") || !hasCombatWarning(output.Warnings, combatnormalize.ReasonCodeActorCanonicalized) || !hasCombatWarning(output.Warnings, combatnormalize.ReasonCodeTargetCanonicalized) { - t.Fatalf("warnings = %#v, want relatedness and registry normalization warnings", output.Warnings) + if !hasCombatWarning(output.Warnings, combatnormalize.ReasonCodeActorCanonicalized) { + t.Fatalf("warnings = %#v, want registry normalization warning", output.Warnings) } if len(output.Manifest.References) != 2 { t.Fatalf("manifest references = %#v, want separate extract and normalize provenance", output.Manifest.References) @@ -140,9 +140,9 @@ func TestProductionCombatPipelineAttributesExhaustedInvalidEnumsToShapeValidatio t.Fatalf("Resolve() error = %v, want nil", err) } client := &fakeCombatLLMClient{responses: []string{ - combatTestInvalidEnumResponse("unsupported", "attack"), - combatTestInvalidEnumResponse("turn", "unsupported"), - combatTestInvalidEnumResponse("unsupported", "unsupported"), + combatTestInvalidEnumResponse("unsupported"), + combatTestInvalidEnumResponse("invalid"), + combatTestInvalidEnumResponse("unknown"), }} prepared, err := pipeline.Prepare(effective.ResolvedPipeline, registries, pipeline.ModuleDependencies{LLM: client}) if err != nil { @@ -312,12 +312,12 @@ func (client *fakeCombatLLMClient) CompleteStructured(ctx context.Context, req c return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "combat-fake"}, nil } -func combatTestInvalidEnumResponse(turnKind, category string) string { - return fmt.Sprintf(`{"combat_turns":[{"actor":"Aria","turn_kind":%q,"round":1,"actions":[{"category":%q,"declaration":"watches","targets":["Mira"],"resolution":null}],"summary":"invalid candidate","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`, turnKind, category) +func combatTestInvalidEnumResponse(turnKind string) string { + return fmt.Sprintf(`{"combat_turns":[{"actor":"Aria","turn_kind":%q,"source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`, turnKind) } -func combatTestTurnResponse(actor, turnKind, declaration, target string, round, unit int) string { - return fmt.Sprintf(`{"combat_turns":[{"actor":%q,"turn_kind":%q,"round":%d,"actions":[{"category":"attack","declaration":%q,"targets":[%q],"resolution":"observed"}],"summary":%q,"source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, actor, turnKind, round, declaration, target, declaration, unit, unit) +func combatTestTurnResponse(actor, turnKind string, unit int) string { + return fmt.Sprintf(`{"combat_turns":[{"actor":%q,"turn_kind":%q,"source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, actor, turnKind, unit, unit) } func hasCombatWarning(warnings []contracts.Warning, reason string) bool { diff --git a/internal/modules/integration/dnd_npc_grounded_test.go b/internal/modules/integration/dnd_npc_grounded_test.go index d2321c3..d05a8d8 100644 --- a/internal/modules/integration/dnd_npc_grounded_test.go +++ b/internal/modules/integration/dnd_npc_grounded_test.go @@ -120,8 +120,8 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T combatValue = decoded } } - if len(combatValue.CombatTurns) != 1 || combatValue.CombatTurns[0].Actor != "Mira Thorn" || combatValue.CombatTurns[0].Actions[0].Targets[0] != "Hooded Guard" { - t.Fatalf("combat output = %#v, want registry-normalized actor and target", combatValue) + if len(combatValue.CombatTurns) != 1 || combatValue.CombatTurns[0].Actor != "Mira Thorn" { + t.Fatalf("combat output = %#v, want registry-normalized actor", combatValue) } assertCurrentEvidence(t, combatValue.CombatTurns[0].SourceRefs) } @@ -179,9 +179,8 @@ func (client *groundedDNDLLMClient) CompleteStructured(ctx context.Context, requ }}} case combatextract.PromptID: payload = map[string]any{"combat_turns": []any{map[string]any{ - "actor": "Mira Thorn", "turn_kind": "turn", "round": 1, - "actions": []any{map[string]any{"category": "attack", "declaration": "watches", "targets": []string{"Hooded Guard"}, "resolution": "observed"}}, - "summary": "Mira Thorn watches the gate.", + "actor": "Mira Thorn", + "turn_kind": "turn", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}, }}} default: