diff --git a/assets/dnd/item-registry/normalize/prompts/instructions.md b/assets/dnd/item-registry/normalize/prompts/instructions.md index f719e46b..fa9f22bd 100644 --- a/assets/dnd/item-registry/normalize/prompts/instructions.md +++ b/assets/dnd/item-registry/normalize/prompts/instructions.md @@ -5,5 +5,5 @@ evidence, similar objects, or a shared owner as sufficient. Keep currency denominations and materially different item types separate. Keep uncertain aliases separate. Do not infer an item property or uniqueness. -When selecting a canonical display name, choose one supplied candidate name -that is the clearest established designation. +Set `canonical_candidate_number` to the supplied candidate number whose label +is the clearest established designation. diff --git a/assets/dnd/location-registry/normalize/prompts/instructions.md b/assets/dnd/location-registry/normalize/prompts/instructions.md index d24d9087..e344c5bd 100644 --- a/assets/dnd/location-registry/normalize/prompts/instructions.md +++ b/assets/dnd/location-registry/normalize/prompts/instructions.md @@ -5,4 +5,5 @@ nearby evidence, nested places, or generic labels as sufficient. Keep parent and child places separate, as well as similarly named places and uncertain aliases. -When selecting a canonical display name, prefer the clearest established name. +Set `canonical_candidate_number` to the supplied candidate number whose label +is the clearest established name. diff --git a/assets/dnd/npc-registry/normalize/prompts/instructions.md b/assets/dnd/npc-registry/normalize/prompts/instructions.md index 1317b2c2..daac0cc4 100644 --- a/assets/dnd/npc-registry/normalize/prompts/instructions.md +++ b/assets/dnd/npc-registry/normalize/prompts/instructions.md @@ -3,7 +3,8 @@ contextual labels and cited transcript windows. Preserve distinct individuals even when their names are similar or their contextual descriptions are identical. -When selecting a canonical display name, prefer a complete, stable proper name +Set `canonical_candidate_number` to the supplied candidate number whose label +is the preferred canonical display name. Prefer a complete, stable proper name over an abbreviation. Prefer an unadorned proper name over that name plus a contextual class, role, title, or relationship descriptor unless the transcript establishes the descriptor as part of the person's name. A longer display name diff --git a/assets/generic/normalize/deduplication/prompts/candidates.md b/assets/generic/normalize/deduplication/prompts/candidates.md index 5fae57e4..1e8b9b50 100644 --- a/assets/generic/normalize/deduplication/prompts/candidates.md +++ b/assets/generic/normalize/deduplication/prompts/candidates.md @@ -1,3 +1,3 @@ -Candidate material: +Candidate material, including the exact valid candidate-number range: {{ input "candidates" }} diff --git a/assets/generic/normalize/deduplication/prompts/protocol.md b/assets/generic/normalize/deduplication/prompts/protocol.md index cdd94d73..6fb9788a 100644 --- a/assets/generic/normalize/deduplication/prompts/protocol.md +++ b/assets/generic/normalize/deduplication/prompts/protocol.md @@ -1,7 +1,9 @@ -Use only the positive integer `candidate_id` values supplied in the candidate material. +Use only the positive integer `candidate_number` values supplied in the candidate material. Valid candidate numbers are exactly the inclusive `first` through `last` values declared in `candidate_number_range`; use the explicit number attached to each candidate. -Return a duplicate group only when the evidence supports that every selected candidate describes the same underlying entity. Each group must contain at least two distinct candidate IDs, and its `canonical_candidate_id` must be one of those IDs. A candidate may appear in at most one group. +Transcript unit `id` values and evidence `start_unit_id` and `end_unit_id` values identify source positions. They are not candidate numbers and must never be used in `candidate_numbers` or `canonical_candidate_number`. -Omit uncertain matches and candidates that should remain distinct. Do not invent candidates or infer an ID from list position. An empty `duplicate_groups` array is valid. +Return a duplicate group only when the evidence supports that every selected candidate describes the same underlying entity. Each group must contain at least two distinct candidate numbers, and its `canonical_candidate_number` must be one of those numbers. A candidate may appear in at most one group. -The response must conform exactly to the selected JSON schema. Return IDs only: do not copy candidate names, evidence, transcript text, source identifiers, or source ranges into the response. +Omit uncertain matches and candidates that should remain distinct. Do not invent candidates or infer a number that is not explicitly supplied. An empty `duplicate_groups` array is valid. + +The response must conform exactly to the selected JSON schema. Return candidate numbers only: do not copy candidate names, evidence, transcript text, source identifiers, or source ranges into the response. diff --git a/assets/generic/normalize/deduplication/schemas/semantic_reconciliation_llm.v1.json b/assets/generic/normalize/deduplication/schemas/semantic_reconciliation_llm.v1.json index a46f014b..9675cce8 100644 --- a/assets/generic/normalize/deduplication/schemas/semantic_reconciliation_llm.v1.json +++ b/assets/generic/normalize/deduplication/schemas/semantic_reconciliation_llm.v1.json @@ -11,9 +11,9 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["candidate_ids", "canonical_candidate_id"], + "required": ["candidate_numbers", "canonical_candidate_number"], "properties": { - "candidate_ids": { + "candidate_numbers": { "type": "array", "minItems": 2, "items": { @@ -21,7 +21,7 @@ "minimum": 1 } }, - "canonical_candidate_id": { + "canonical_candidate_number": { "type": "integer", "minimum": 1 } diff --git a/docs/internal/dnd.md b/docs/internal/dnd.md index 32d3c3ca..a3dfd8c6 100644 --- a/docs/internal/dnd.md +++ b/docs/internal/dnd.md @@ -266,13 +266,16 @@ proposal to reconcile identity groups. The three registry normalizers instantiate the domain-neutral `internal/framework/semanticreconcile` engine with default bounds. Each -eligible candidate receives a contiguous, one-based `candidate_id` for that -request. The model sees that handle, the candidate label and source-free -evidence ranges, plus bounded transcript windows; it returns only duplicate +eligible candidate receives a contiguous, one-based `candidate_number` for that +request, and candidate material declares the exact inclusive range. The model +sees that handle, the candidate label and source-free evidence ranges, plus +bounded transcript windows; it returns only duplicate groups of supplied handles and one supplied canonical handle per group. It never returns names, evidence, durable IDs, or replacement records. Identical labels and evidence remain independently selectable because their handles are -distinct. +distinct. Transcript unit `id` values and evidence `start_unit_id` and +`end_unit_id` values are source positions in a separate namespace and are +never valid candidate numbers. The generic core owns the mandatory handle protocol, candidate and transcript presentation, the private response schema, source-reference validation, diff --git a/docs/internal/modules.md b/docs/internal/modules.md index cb0a2f4b..aa607d2d 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -95,19 +95,24 @@ source-backed artifact-family normalizer projects its deterministic records into contextual candidates and owned typed record envelopes, supplies its chosen prompt identity and resolved LLM profile, and constructs an engine with explicit limits. The core filters invalid evidence, assigns contiguous -request-local integer handles, renders bounded candidate and transcript -materials, invokes the structured-completion boundary, and assesses the -returned duplicate groups into a stable non-overlapping plan. +one-based request-local candidate numbers, renders bounded candidate and +transcript materials, invokes the structured-completion boundary, and assesses +the returned duplicate groups into a stable non-overlapping plan. Candidate +material declares the exact inclusive number range for the request. Transcript +unit IDs and evidence range endpoints remain source positions in a separate +namespace and are never valid candidate numbers. The normalizer then applies that plan through a typed `ApplicationPolicy`. The core preserves ungrouped records, contribution order, and provenance while the artifact family owns group guards, field and evidence consolidation, durable -ID derivation, retry and fallback presentation, classified diagnostics, and postconditions. -Request-local handles do not enter the typed value or durable artifact. Fewer -than two eligible candidates skips model invocation; exceeding a candidate or -combined-material bound preserves the deterministic result under the family's -fallback policy. Provider, transport, cancellation, and context-construction -failures remain execution errors. +ID derivation, retry and fallback presentation, classified diagnostics, and +postconditions. +These candidate numbers are the concrete private representation of ADR-0013's +request-local handles; they do not enter the typed value or durable artifact. +Fewer than two eligible candidates skips model invocation; exceeding a +candidate or combined-material bound preserves the deterministic result under +the family's fallback policy. Provider, transport, cancellation, and +context-construction failures remain execution errors. When the engine actually makes a proposal call, its typed result carries the owned exact proposal response under the same correction contract as other diff --git a/docs/roadmap/semantic-reconciliation-candidate-numbers.md b/docs/roadmap/semantic-reconciliation-candidate-numbers.md new file mode 100644 index 00000000..10339348 --- /dev/null +++ b/docs/roadmap/semantic-reconciliation-candidate-numbers.md @@ -0,0 +1,270 @@ +# Semantic Reconciliation Candidate Numbers + +## Purpose + +Make the private semantic-reconciliation protocol easier for smaller models to +follow by clearly separating request-local candidate selection from transcript +unit addressing. Replace the ambiguous `candidate_id` vocabulary with explicit +one-based candidate numbers, declare the valid number range for every request, +and ensure both initial and corrective instructions describe the exact response +the model can produce. + +## Motivation And Current State + +The domain-neutral semantic-reconciliation core supplies each eligible registry +candidate with a contiguous, one-based integer handle. The model returns groups +of those handles, deterministic code resolves them to retained typed records, +and unsafe groups are discarded without merging their candidates. This is the +architecture required by ADR-0013 and correctly prevents model-generated values +from becoming durable entity identity. + +The current private JSON vocabulary calls these handles `candidate_id`, +`candidate_ids`, and `canonical_candidate_id`. Candidate material and transcript +context also contain `start_unit_id`, `end_unit_id`, and transcript unit `id` +values. Although the shared protocol says to use only supplied candidate IDs, +all of these values are nearby integers and the term “ID” does not clearly +communicate which namespace is request-local. + +An observed small-model run exhausted semantic-proposal retries after returning +a duplicate group with an unknown candidate member and fewer than two remaining +distinct valid members. The safe fallback behaved correctly: the group was +omitted and all affected items were preserved separately. The issue pattern is +consistent with confusing a transcript or evidence unit ID for a candidate +handle, though an ordinary warning intentionally does not retain the exact +model response needed to prove that inference. + +The item-specific semantic instructions contain a related mismatch. They tell +the model to choose a supplied candidate name as canonical even though the +private response can return only the handle of the candidate whose label should +be retained. + +## Decisions + +### Use candidate-number vocabulary throughout the private protocol + +The model-visible candidate material will use `candidate_number`. Duplicate +groups will use `candidate_numbers` and `canonical_candidate_number`. Matching +Go names, internal comments, diagnostics, tests, and current-behavior internal +documentation will use “candidate number” where they refer to this private +request-local value. + +The rename applies coherently to the generic semantic-reconciliation core and +all current consumers: NPC, item, and location registry normalization. It does +not change the abstract architectural term “request-local candidate handle” in +ADR-0013; a candidate number remains the concrete representation of that +handle. + +The old JSON property names will not be accepted as aliases. No compatibility +adapter, dual-field schema, migration reader, or legacy prompt will remain. +This is a private model contract, not a durable artifact contract. + +### Declare the valid range in each request + +Prepared candidate material will contain an explicit request-level range: + +```json +{ + "candidate_number_range": { + "first": 1, + "last": 12 + }, + "candidates": [ + { + "candidate_number": 1, + "label": "Example", + "source_refs": [] + } + ] +} +``` + +`first` and `last` are required integers. For every model invocation, `first` +is 1 and `last` is the number of eligible candidates in the prepared request. +The candidate list remains contiguous and one-based. Preparation owns this +derived declaration and must keep it consistent with the emitted candidate +objects and retained mappings. + +The initial shared protocol will state that valid candidate numbers are exactly +the declared inclusive range and that the model must use the explicit +`candidate_number` attached to each candidate. Transcript unit `id` values and +evidence `start_unit_id` and `end_unit_id` values identify source positions; +they are never candidate numbers and must never appear in the response merely +because they are integers. + +### Make retry guidance request-aware + +When proposal assessment finds a non-positive or unknown member or canonical +number, the model-facing correction will include the request’s exact valid +inclusive candidate-number range. It will retain response-local duplicate-group +ordinals so the model can locate the defective group in the appended prior +response. It will explicitly distinguish candidate numbers from transcript and +evidence unit IDs. + +Correction guidance must remain bounded and semantically meaningful. It must +not expose internal issue categories, reason codes, Go paths, durable entity +IDs, source identities, or raw operator errors. The framework will continue to +append the exact latest defective response separately under the existing +replacement-request protocol; correction prose must not duplicate it. + +### Describe canonical selection in terms of the actual response + +The item-registry normalization instructions will say to set +`canonical_candidate_number` to the supplied candidate number whose label is +the clearest transcript-supported designation. Equivalent NPC and location +instructions must be reviewed for the same mismatch and corrected if present. +Domain instructions continue to decide semantic equivalence and preferred +canonical labels; the shared protocol continues to own selection mechanics. + +## Target End State + +- Every semantic-reconciliation prompt presents one unambiguous, request-local + candidate-number namespace. +- Candidate material declares the exact valid inclusive range and assigns every + eligible candidate one explicit number in that range. +- Transcript and evidence unit identifiers are clearly identified as a + separate namespace that is never valid in proposal selection fields. +- Structured responses contain only `duplicate_groups`, + `candidate_numbers`, and `canonical_candidate_number` at their existing + logical levels; the former `candidate_id` property family is rejected. +- Proposal assessment remains deterministic, validates exact membership in the + prepared candidate set, discards unsafe groups, applies independent safe + groups, and preserves affected candidates separately on fallback. +- A retry for an invalid selection tells the model which response-local groups + need correction and the exact valid candidate-number range without exposing + internal diagnostic vocabulary. +- NPC, item, and location registry reconciliation use the same generic private + protocol, while their typed consolidation and identity rules remain + domain-owned. +- Durable registry schemas, artifact IDs, evidence ranges, warning policy, + retry budgets, and PromptKit structural-repair budgets do not change. + +## Required Work + +### Generic protocol and model + +- Rename the model-facing fields in the generic candidate material, proposal + response types, private response schema, shared protocol prompt, and + presentation prompt. +- Rename internal semantic-reconciliation fields and accessors where they + represent request-local candidate numbers, so implementation terminology + does not immediately drift back toward ambiguous IDs. +- Add the required `candidate_number_range` object to prepared material and + derive it from the filtered eligible candidate set. +- Preserve the current contiguous one-based assignment, immutable retained + mapping, bounded material limits, stable plan ordering, and exact typed + resolution behavior. +- Keep exact candidate membership as deterministic semantic validation. The + shared static response schema may enforce positive integers and structural + shape, but it must not pretend to encode a request-specific maximum that it + cannot know. + +### Request-aware corrective feedback + +- Make the valid candidate-number range available to the generic correction + renderer through owned preparation or reconciliation result state. +- Add the exact range and the candidate-number/source-unit distinction to + corrections for unknown or non-positive proposal members and canonical + selections. Retain existing guidance for repeated members, undersized groups, + canonical membership, and overlapping groups. +- Preserve stable grouping, de-duplication, response-local group ordinals, byte + bounds, and separation between operator diagnostics and model guidance. +- Ensure NPC, item, and location normalizers use the generic renderer rather + than duplicating range or namespace language. Item-specific currency + guidance remains an additive domain rule. + +### D&D semantic instructions + +- Rewrite the item-registry canonical-selection sentence around + `canonical_candidate_number` and the selected candidate’s label. +- Review the NPC- and location-registry normalization instructions and apply + the same correction wherever they describe returning a name rather than + selecting its candidate number. +- Do not duplicate the generic number-range or namespace rules in D&D-specific + files; those rules belong in the mandatory shared protocol. + +### Fingerprints and checkpoints + +- Revise the existing private v1 prompt and schema assets in place; do not add + legacy schema variants or compatibility aliases. +- Ensure changed protocol, candidate-material, response-schema, correction, + and typed-policy behavior participates in the established prompt, asset, and + checkpoint fingerprint mechanisms. Explicitly bump a core or local policy + fingerprint wherever the effective behavior is not already covered by an + asset digest. +- A checkpoint produced under the former candidate-ID contract must not be + reused as compatible with the candidate-number contract. + +## Testing + +Add lean offline coverage at the narrowest stable boundaries: + +- preparation tests should prove contiguous candidate numbers, an exact + matching range declaration, filtering behavior, immutable mappings, and the + absence of former model-facing field names; +- private-schema tests should accept the candidate-number proposal shape and + reject the former candidate-ID fields, missing required properties, unknown + properties, non-integer numbers, non-positive numbers, and undersized arrays; +- proposal-assessment tests should retain the existing safety matrix using the + renamed vocabulary and prove that exact prepared-set membership still owns + acceptance; +- correction tests should prove that an unknown candidate number produces the + exact valid range and namespace distinction without internal issue labels, + reason codes, or operator paths; +- prompt tests should prove selection of the shared protocol and the corrected + domain instruction, without snapshotting complete prose or exact message + lengths; and +- representative NPC, item, and location normalizer tests should prove safe + application, corrective retry, and exhaustion fallback through the renamed + private contract. + +Do not add live-provider tests to the default suite. After implementation, a +maintainer may repeat the previously problematic small-model workload as +observational validation, preferably with an explicitly requested debug bundle +if the warning persists. + +## Documentation + +Update `docs/internal/modules.md` as the canonical description of the generic +semantic-reconciliation mechanism and `docs/internal/dnd.md` for its three D&D +registry consumers. Describe candidate numbers as request-local handles, +including the declared range and namespace separation, without repeating the +private JSON schema in both documents. + +No new ADR is required. This work reinforces ADR-0012’s prohibition on opaque +model-reproduced identity and ADR-0013’s accepted request-local-handle design; +it changes only the concrete private vocabulary and reliability presentation. +The architecture policy already owns the durable invariant and need not change +unless implementation reveals a genuinely new architectural decision. + +## Non-Goals + +- Changing durable NPC, item, or location artifact schemas or identifiers. +- Asking the model to return candidate labels, evidence ranges, transcript + text, source identities, or replacement records. +- Accepting both candidate-ID and candidate-number response shapes. +- Guessing that an unknown number denotes a transcript unit, list position, or + different candidate and silently repairing the proposal. +- Weakening deterministic proposal validation or typed domain guards. +- Increasing stage retries or PromptKit structural-repair attempts to mask an + ambiguous protocol. +- Introducing a request-specific dynamic response schema in this work set. + +## Acceptance Criteria + +- No model-facing semantic-reconciliation asset or private JSON shape uses the + former `candidate_id`, `candidate_ids`, or `canonical_candidate_id` fields. +- Candidate material and retry guidance expose the exact valid inclusive + candidate-number range for the current request. +- Shared instructions explicitly prohibit using transcript unit IDs or evidence + range endpoints as candidate numbers. +- Item, NPC, and location canonical-selection instructions refer to the actual + candidate-number response contract. +- Invalid or ambiguous proposals remain safely rejected without false merges; + retry exhaustion preserves affected candidates separately and emits the + existing process warning. +- Durable outputs and public schemas are unchanged, while incompatible private + checkpoints are invalidated. +- Canonical internal documentation records the implemented terminology and + ownership boundaries. +- Focused tests, `go test ./...`, `go vet ./...`, and + `go build ./cmd/notarius` pass in a supported development environment. diff --git a/internal/framework/pipeline/producer_attempts_test.go b/internal/framework/pipeline/producer_attempts_test.go index 8e3c9f2b..e854e1cf 100644 --- a/internal/framework/pipeline/producer_attempts_test.go +++ b/internal/framework/pipeline/producer_attempts_test.go @@ -239,8 +239,8 @@ func TestRunProducerAttemptsUsesModuleRetryBudgetAndFallback(t *testing.T) { t.Run("feedback retry", func(t *testing.T) { const ( - defective = `{"duplicate_groups":[{"candidate_ids":[1,99],"canonical_candidate_id":1}]}` - guidance = "Use only candidate IDs from the supplied candidate list. Return one complete corrected response." + defective = `{"duplicate_groups":[{"candidate_numbers":[1,99],"canonical_candidate_number":1}]}` + guidance = "Use only candidate numbers from the supplied candidate list. Return one complete corrected response." ) var observed *contracts.SemanticCorrection terminal, err := runProducerAttempts(context.Background(), producerAttemptConfig{Retries: 1, Policy: DefaultValidationPolicy()}, func(_ context.Context, request producerAttemptRequest) (producerAttemptOutput, error) { diff --git a/internal/framework/pipeline/runner_normalize_retry_test.go b/internal/framework/pipeline/runner_normalize_retry_test.go index 522fc365..0a2cc613 100644 --- a/internal/framework/pipeline/runner_normalize_retry_test.go +++ b/internal/framework/pipeline/runner_normalize_retry_test.go @@ -155,8 +155,8 @@ func TestRunnerHandlesRetryableNormalizeFallbacks(t *testing.T) { func TestRunnerForwardsModuleRequestedNormalizeCorrection(t *testing.T) { const ( - defective = `{"duplicate_groups":[{"candidate_ids":[1,99],"canonical_candidate_id":1}]}` - guidance = "Duplicate group 1 must use only supplied candidate IDs. Return one complete corrected response." + defective = `{"duplicate_groups":[{"candidate_numbers":[1,99],"canonical_candidate_number":1}]}` + guidance = "Duplicate group 1 must use only supplied candidate numbers. Return one complete corrected response." ) prepared := preparedAttemptDebugPipeline(t) lane := &prepared.Steps[0].lanes[0] diff --git a/internal/framework/semanticreconcile/assets_test.go b/internal/framework/semanticreconcile/assets_test.go index c703c6ef..e0142e67 100644 --- a/internal/framework/semanticreconcile/assets_test.go +++ b/internal/framework/semanticreconcile/assets_test.go @@ -31,7 +31,7 @@ func TestRegisterAssetsPreparesGenericPromptOffline(t *testing.T) { prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{ PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "semantic-reconciliation-test", Inputs: map[string]promptkit.ArtifactRef{ - "candidates": promptkit.Inline(`{"candidates":[{"candidate_id":1,"label":"Mira"},{"candidate_id":2,"label":"Captain Mira"}]}`), + "candidates": promptkit.Inline(`{"candidate_number_range":{"first":1,"last":2},"candidates":[{"candidate_number":1,"label":"Mira"},{"candidate_number":2,"label":"Captain Mira"}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[{"unit_id":7,"text":"Mira arrived."}]}]}`), }, }) @@ -60,15 +60,15 @@ func TestRegisterAssetsPreparesGenericPromptOffline(t *testing.T) { } } protocol := prepared.Messages[1].Content - for _, requirement := range []string{"positive integer", "Return IDs only", "do not copy candidate names", "source ranges"} { + for _, requirement := range []string{"candidate_number_range", "Return candidate numbers only", "do not copy candidate names", "start_unit_id", "not candidate numbers"} { if !strings.Contains(protocol, requirement) { t.Fatalf("protocol message = %q, want requirement %q", protocol, requirement) } } - if !strings.Contains(prepared.Messages[3].Content, `"candidate_id":1`) || strings.Contains(prepared.Messages[3].Content, `"windows"`) { + if !strings.Contains(prepared.Messages[3].Content, `"candidate_number":1`) || strings.Contains(prepared.Messages[3].Content, `"windows"`) { t.Fatalf("candidate message = %q, want only integer candidate material", prepared.Messages[3].Content) } - if !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, `"candidate_id"`) { + if !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, `"candidate_number"`) { t.Fatalf("transcript message = %q, want only transcript windows", prepared.Messages[4].Content) } } diff --git a/internal/framework/semanticreconcile/engine.go b/internal/framework/semanticreconcile/engine.go index 0134c1a0..48528b3d 100644 --- a/internal/framework/semanticreconcile/engine.go +++ b/internal/framework/semanticreconcile/engine.go @@ -65,12 +65,13 @@ const ( // Result owns the safe plan and neutral diagnostics from one call. type Result struct { - disposition ResultDisposition - plan Plan - issues []Issue - discardedGroupCount int - candidateMappings []CandidateMapping - modelCandidate *contracts.ModelCandidate + disposition ResultDisposition + plan Plan + issues []Issue + discardedGroupCount int + candidateNumberRange CandidateNumberRange + candidateMappings []CandidateMapping + modelCandidate *contracts.ModelCandidate } // Disposition returns the classified outcome. @@ -85,11 +86,27 @@ func (result Result) Issues() []Issue { return append([]Issue(nil), result.issue // DiscardedGroupCount returns the number of excluded proposal groups. func (result Result) DiscardedGroupCount() int { return result.discardedGroupCount } -// CandidateMappings returns the request-local handle mapping used for this call. +// CandidateNumberRange returns the exact request-local range used for this call. +func (result Result) CandidateNumberRange() CandidateNumberRange { + return result.candidateNumberRange +} + +// CandidateMappings returns the request-local number mapping used for this call. func (result Result) CandidateMappings() []CandidateMapping { return append([]CandidateMapping(nil), result.candidateMappings...) } +// CorrectionDetails returns bounded model-facing descriptions of the result's +// proposal issues using the exact candidate-number range for this call. +func (result Result) CorrectionDetails() ([]string, error) { + return CorrectionDetails(result.Issues(), result.candidateNumberRange) +} + +// CorrectionGuidance returns one bounded replacement request for this result. +func (result Result) CorrectionGuidance(additionalDetails ...string) (string, error) { + return CorrectionGuidance(result.Issues(), result.candidateNumberRange, additionalDetails...) +} + // ModelCandidate returns an owned copy of the proposal response when a model // completion produced this result. func (result Result) ModelCandidate() *contracts.ModelCandidate { @@ -158,7 +175,10 @@ func (engine *Engine) Reconcile(ctx context.Context, request Request) (Result, e if err != nil { return Result{}, fmt.Errorf("semantic reconciliation %q: prepare materials: %w", request.StageName, err) } - result := Result{candidateMappings: preparation.CandidateMappings()} + result := Result{ + candidateNumberRange: preparation.CandidateNumberRange(), + candidateMappings: preparation.CandidateMappings(), + } switch preparation.Disposition() { case InsufficientCandidates: result.disposition = SkippedInsufficientCandidates diff --git a/internal/framework/semanticreconcile/engine_test.go b/internal/framework/semanticreconcile/engine_test.go index 884cd840..08faa0e0 100644 --- a/internal/framework/semanticreconcile/engine_test.go +++ b/internal/framework/semanticreconcile/engine_test.go @@ -50,7 +50,7 @@ func TestNewEngineValidatesConstruction(t *testing.T) { func TestEnginePropagatesRequestAndAssessesResponse(t *testing.T) { client := &recordingReconciliationClient{responses: []ProposalResponse{{DuplicateGroups: []DuplicateGroup{{ - CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2, + CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 2, }}}}} engine := newTestEngine(t, client, DefaultLimits()) request := readyEngineRequest() @@ -69,6 +69,9 @@ func TestEnginePropagatesRequestAndAssessesResponse(t *testing.T) { if result.Disposition() != Complete || result.DiscardedGroupCount() != 0 || len(result.Issues()) != 0 { t.Fatalf("result = disposition %v discarded %d issues %#v", result.Disposition(), result.DiscardedGroupCount(), result.Issues()) } + if got, want := result.CandidateNumberRange(), (CandidateNumberRange{First: 1, Last: 2}); got != want { + t.Fatalf("candidate number range = %#v, want %#v", got, want) + } groups := result.Plan().Groups() if len(groups) != 1 || !reflect.DeepEqual(groups[0].MemberPositions(), []int{0, 1}) || groups[0].CanonicalPosition() != 1 { t.Fatalf("safe plan = %#v", groups) @@ -105,7 +108,7 @@ func TestEngineClassifiesSemanticAndTransportOutcomes(t *testing.T) { wantError error }{ {name: "empty groups complete", response: ProposalResponse{DuplicateGroups: []DuplicateGroup{}}, want: Complete, wantCandidate: true}, - {name: "discarded proposal retryable", response: ProposalResponse{DuplicateGroups: []DuplicateGroup{{CandidateIDs: []int{1, 99}, CanonicalCandidateID: 1}}}, want: RetryableDiscardedProposalGroups, wantDiscard: 1, wantIssues: true, wantCandidate: true}, + {name: "discarded proposal retryable", response: ProposalResponse{DuplicateGroups: []DuplicateGroup{{CandidateNumbers: []int{1, 99}, CanonicalCandidateNumber: 1}}}, want: RetryableDiscardedProposalGroups, wantDiscard: 1, wantIssues: true, wantCandidate: true}, {name: "invalid structured output retryable", completion: fmt.Errorf("decode response: %w", contracts.ErrInvalidStructuredOutput), want: RetryableInvalidStructuredOutput}, {name: "transport failure", completion: transportErr, wantError: transportErr}, } @@ -213,8 +216,8 @@ func TestEngineRejectsInvalidInvocationAndHonorsCancellation(t *testing.T) { func TestEngineCallsAreIndependentAndResultsAreOwned(t *testing.T) { client := &recordingReconciliationClient{responses: []ProposalResponse{ {DuplicateGroups: []DuplicateGroup{ - {CandidateIDs: []int{1, 2}, CanonicalCandidateID: 1}, - {CandidateIDs: []int{2, 99}, CanonicalCandidateID: 2}, + {CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 1}, + {CandidateNumbers: []int{2, 99}, CanonicalCandidateNumber: 2}, }}, {DuplicateGroups: []DuplicateGroup{}}, }} @@ -280,7 +283,7 @@ func cloneProposalResponse(response ProposalResponse) ProposalResponse { cloned := ProposalResponse{DuplicateGroups: make([]DuplicateGroup, len(response.DuplicateGroups))} for index, group := range response.DuplicateGroups { cloned.DuplicateGroups[index] = DuplicateGroup{ - CandidateIDs: append([]int(nil), group.CandidateIDs...), CanonicalCandidateID: group.CanonicalCandidateID, + CandidateNumbers: append([]int(nil), group.CandidateNumbers...), CanonicalCandidateNumber: group.CanonicalCandidateNumber, } } return cloned diff --git a/internal/framework/semanticreconcile/identity.go b/internal/framework/semanticreconcile/identity.go index cdc78e6e..90046445 100644 --- a/internal/framework/semanticreconcile/identity.go +++ b/internal/framework/semanticreconcile/identity.go @@ -13,7 +13,7 @@ import ( ) // Policy identifies the framework-owned reconciliation and assessment rules. -const Policy = "semantic_reconciliation.v2" +const Policy = "semantic_reconciliation.v3" var _ contracts.ManifestMetadataProvider = (*Engine)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Engine)(nil) diff --git a/internal/framework/semanticreconcile/preparation.go b/internal/framework/semanticreconcile/preparation.go index 60365661..248eba6d 100644 --- a/internal/framework/semanticreconcile/preparation.go +++ b/internal/framework/semanticreconcile/preparation.go @@ -70,18 +70,26 @@ const ( LimitExceeded ) -// CandidateMapping relates one model-visible request-local ID to the +// CandidateMapping relates one model-visible request-local number to the // corresponding zero-based position in the caller's candidate slice. type CandidateMapping struct { - CandidateID int + CandidateNumber int CandidatePosition int } +// CandidateNumberRange is the exact inclusive request-local number range +// presented to the model. Ready preparations always use a one-based range. +type CandidateNumberRange struct { + First int `json:"first"` + Last int `json:"last"` +} + // Preparation owns the visible candidate mapping and prompt materials. type Preparation struct { - disposition Disposition - mappings []CandidateMapping - materials contracts.LLMInputSet + disposition Disposition + candidateNumberRange CandidateNumberRange + mappings []CandidateMapping + materials contracts.LLMInputSet } // Disposition returns the preparation outcome. @@ -94,6 +102,12 @@ func (preparation Preparation) CandidateMappings() []CandidateMapping { return append([]CandidateMapping(nil), preparation.mappings...) } +// CandidateNumberRange returns the exact request-local range assigned during +// preparation. A zero value means that no eligible candidates were present. +func (preparation Preparation) CandidateNumberRange() CandidateNumberRange { + return preparation.candidateNumberRange +} + // Materials returns independently owned candidate and transcript materials. // It is empty unless Disposition returns Ready. func (preparation Preparation) Materials() contracts.LLMInputSet { @@ -106,13 +120,14 @@ type sourceRange struct { } type visibleCandidate struct { - CandidateID int `json:"candidate_id"` - Label string `json:"label"` - SourceRefs []sourceRange `json:"source_refs"` + CandidateNumber int `json:"candidate_number"` + Label string `json:"label"` + SourceRefs []sourceRange `json:"source_refs"` } type candidateInput struct { - Candidates []visibleCandidate `json:"candidates"` + CandidateNumberRange CandidateNumberRange `json:"candidate_number_range"` + Candidates []visibleCandidate `json:"candidates"` } type transcriptInput struct { @@ -168,17 +183,20 @@ func Prepare(document *source.SourceDocument, candidates []Candidate, limits Lim disposition: InsufficientCandidates, mappings: make([]CandidateMapping, len(prepared)), } + if len(prepared) > 0 { + result.candidateNumberRange = CandidateNumberRange{First: 1, Last: len(prepared)} + } views := make([]visibleCandidate, len(prepared)) for index, candidate := range prepared { - candidateID := index + 1 + candidateNumber := index + 1 result.mappings[index] = CandidateMapping{ - CandidateID: candidateID, + CandidateNumber: candidateNumber, CandidatePosition: candidate.position, } views[index] = visibleCandidate{ - CandidateID: candidateID, - Label: candidates[candidate.position].Label, - SourceRefs: cloneSourceRanges(candidate.references), + CandidateNumber: candidateNumber, + Label: candidates[candidate.position].Label, + SourceRefs: cloneSourceRanges(candidate.references), } } if len(prepared) < 2 { @@ -189,7 +207,7 @@ func Prepare(document *source.SourceDocument, candidates []Candidate, limits Lim return result, nil } - candidateContent, withinLimit, err := marshalCandidateInput(views, limits.MaximumMaterialBytes) + candidateContent, withinLimit, err := marshalCandidateInput(result.candidateNumberRange, views, limits.MaximumMaterialBytes) if err != nil { return Preparation{}, fmt.Errorf("prepare semantic reconciliation: encode candidate material: %w", err) } @@ -302,10 +320,14 @@ func coalesceIntervals(intervals []sourceInterval) []sourceInterval { return coalesced } -func marshalCandidateInput(candidates []visibleCandidate, maximumBytes int) ([]byte, bool, error) { +func marshalCandidateInput(numberRange CandidateNumberRange, candidates []visibleCandidate, maximumBytes int) ([]byte, bool, error) { content := make([]byte, 0, min(maximumBytes, 4096)) + encodedRange, err := json.Marshal(numberRange) + if err != nil { + return nil, false, err + } var withinLimit bool - content, withinLimit = appendWithinLimit(content, maximumBytes, []byte(`{"candidates":[`)) + content, withinLimit = appendWithinLimit(content, maximumBytes, []byte(`{"candidate_number_range":`), encodedRange, []byte(`,"candidates":[`)) if !withinLimit { return nil, false, nil } diff --git a/internal/framework/semanticreconcile/preparation_test.go b/internal/framework/semanticreconcile/preparation_test.go index be2934ce..a30336f7 100644 --- a/internal/framework/semanticreconcile/preparation_test.go +++ b/internal/framework/semanticreconcile/preparation_test.go @@ -47,11 +47,14 @@ func TestPrepareBuildsContiguousCandidatesAndOwnedSourceContext(t *testing.T) { t.Fatalf("Prepare() mutated candidates: %#v", candidates) } if got, want := preparation.CandidateMappings(), []CandidateMapping{ - {CandidateID: 1, CandidatePosition: 0}, - {CandidateID: 2, CandidatePosition: 1}, + {CandidateNumber: 1, CandidatePosition: 0}, + {CandidateNumber: 2, CandidatePosition: 1}, }; !reflect.DeepEqual(got, want) { t.Fatalf("CandidateMappings() = %#v, want %#v", got, want) } + if got, want := preparation.CandidateNumberRange(), (CandidateNumberRange{First: 1, Last: 2}); got != want { + t.Fatalf("CandidateNumberRange() = %#v, want %#v", got, want) + } materials := preparation.Materials() if len(materials) != 2 { @@ -79,12 +82,15 @@ func TestPrepareBuildsContiguousCandidatesAndOwnedSourceContext(t *testing.T) { t.Fatal(err) } wantCandidates := []visibleCandidate{ - {CandidateID: 1, Label: "The Tavern", SourceRefs: []sourceRange{{StartUnitID: 10, EndUnitID: 20}, {StartUnitID: 90, EndUnitID: 90}}}, - {CandidateID: 2, Label: "The Tavern", SourceRefs: []sourceRange{{StartUnitID: 10, EndUnitID: 20}, {StartUnitID: 90, EndUnitID: 90}}}, + {CandidateNumber: 1, Label: "The Tavern", SourceRefs: []sourceRange{{StartUnitID: 10, EndUnitID: 20}, {StartUnitID: 90, EndUnitID: 90}}}, + {CandidateNumber: 2, Label: "The Tavern", SourceRefs: []sourceRange{{StartUnitID: 10, EndUnitID: 20}, {StartUnitID: 90, EndUnitID: 90}}}, } if !reflect.DeepEqual(candidatePayload.Candidates, wantCandidates) { t.Fatalf("candidate payload = %#v, want %#v", candidatePayload.Candidates, wantCandidates) } + if got, want := candidatePayload.CandidateNumberRange, (CandidateNumberRange{First: 1, Last: 2}); got != want { + t.Fatalf("candidate number range = %#v, want %#v", got, want) + } var candidateObjects struct { Candidates []map[string]json.RawMessage `json:"candidates"` } @@ -92,12 +98,12 @@ func TestPrepareBuildsContiguousCandidatesAndOwnedSourceContext(t *testing.T) { t.Fatal(err) } for _, candidate := range candidateObjects.Candidates { - if len(candidate) != 3 || candidate["candidate_id"] == nil || candidate["label"] == nil || candidate["source_refs"] == nil { + if len(candidate) != 3 || candidate["candidate_number"] == nil || candidate["label"] == nil || candidate["source_refs"] == nil { t.Fatalf("model-facing candidate fields = %#v", candidate) } } combined := string(materials[candidateInputName].Content) + string(materials[transcriptInputName].Content) - for _, forbidden := range []string{document.ID, "application_entity_id", "private-entity-id"} { + for _, forbidden := range []string{document.ID, "application_entity_id", "private-entity-id", `"candidate_id"`, `"candidate_ids"`, `"canonical_candidate_id"`} { if strings.Contains(combined, forbidden) { t.Fatalf("model material leaked %q: %s", forbidden, combined) } @@ -192,11 +198,14 @@ func TestPrepareFiltersUnsafeCandidatesAndCoalescesAdjacentWindows(t *testing.T) t.Fatalf("Prepare() disposition = %v, error = %v", preparation.Disposition(), err) } if got, want := preparation.CandidateMappings(), []CandidateMapping{ - {CandidateID: 1, CandidatePosition: 0}, - {CandidateID: 2, CandidatePosition: 1}, + {CandidateNumber: 1, CandidatePosition: 0}, + {CandidateNumber: 2, CandidatePosition: 1}, }; !reflect.DeepEqual(got, want) { t.Fatalf("CandidateMappings() = %#v, want %#v", got, want) } + if got, want := preparation.CandidateNumberRange(), (CandidateNumberRange{First: 1, Last: 2}); got != want { + t.Fatalf("CandidateNumberRange() after filtering = %#v, want %#v", got, want) + } var transcript transcriptInput if err := json.Unmarshal(preparation.Materials()[transcriptInputName].Content, &transcript); err != nil { t.Fatal(err) @@ -208,7 +217,7 @@ func TestPrepareFiltersUnsafeCandidatesAndCoalescesAdjacentWindows(t *testing.T) if err != nil { t.Fatal(err) } - if got, want := oneCandidate.CandidateMappings(), []CandidateMapping{{CandidateID: 1, CandidatePosition: 0}}; oneCandidate.Disposition() != InsufficientCandidates || !reflect.DeepEqual(got, want) || len(oneCandidate.Materials()) != 0 { + if got, want := oneCandidate.CandidateMappings(), []CandidateMapping{{CandidateNumber: 1, CandidatePosition: 0}}; oneCandidate.Disposition() != InsufficientCandidates || !reflect.DeepEqual(got, want) || oneCandidate.CandidateNumberRange() != (CandidateNumberRange{First: 1, Last: 1}) || len(oneCandidate.Materials()) != 0 { t.Fatalf("Prepare(one candidate) = disposition %v, mappings %#v, materials %#v", oneCandidate.Disposition(), got, oneCandidate.Materials()) } @@ -216,7 +225,7 @@ func TestPrepareFiltersUnsafeCandidatesAndCoalescesAdjacentWindows(t *testing.T) if err != nil { t.Fatal(err) } - if nilPreparation.Disposition() != InsufficientCandidates || len(nilPreparation.CandidateMappings()) != 0 || len(nilPreparation.Materials()) != 0 { + if nilPreparation.Disposition() != InsufficientCandidates || len(nilPreparation.CandidateMappings()) != 0 || nilPreparation.CandidateNumberRange() != (CandidateNumberRange{}) || len(nilPreparation.Materials()) != 0 { t.Fatalf("Prepare(nil) = disposition %v, mappings %#v, materials %#v", nilPreparation.Disposition(), nilPreparation.CandidateMappings(), nilPreparation.Materials()) } } diff --git a/internal/framework/semanticreconcile/proposal.go b/internal/framework/semanticreconcile/proposal.go index a33fc115..e068d82d 100644 --- a/internal/framework/semanticreconcile/proposal.go +++ b/internal/framework/semanticreconcile/proposal.go @@ -14,11 +14,11 @@ type ProposalResponse struct { DuplicateGroups []DuplicateGroup `json:"duplicate_groups"` } -// DuplicateGroup proposes supplied request-local candidate IDs that may denote -// one entity and identifies one supplied member as canonical. +// DuplicateGroup proposes supplied request-local candidate numbers that may +// denote one entity and identifies one supplied member as canonical. type DuplicateGroup struct { - CandidateIDs []int `json:"candidate_ids"` - CanonicalCandidateID int `json:"canonical_candidate_id"` + CandidateNumbers []int `json:"candidate_numbers"` + CanonicalCandidateNumber int `json:"canonical_candidate_number"` } // IssueCategory identifies one stable proposal safety failure. @@ -47,14 +47,14 @@ var allIssueCategories = []IssueCategory{ } var issueCorrectionProse = map[IssueCategory]string{ - IssueMemberNonPositive: "Use only positive candidate IDs from the supplied candidate list.", - IssueMemberUnknown: "Remove every candidate ID that is not present in the supplied candidate list.", - IssueRepeatedMember: "List each candidate ID at most once within the duplicate group.", - IssueFewerThanTwoMembers: "Include at least two distinct candidate IDs, or omit the duplicate group.", - IssueCanonicalNonPositive: "Choose a positive canonical_candidate_id from the supplied candidate list.", - IssueCanonicalUnknown: "Choose canonical_candidate_id from the supplied candidate list.", - IssueCanonicalNotMember: "Make canonical_candidate_id one of the candidate_ids in the same duplicate group.", - IssueOverlappingMember: "Place each candidate ID in at most one duplicate group.", + IssueMemberNonPositive: "Use only candidate numbers in %s.", + IssueMemberUnknown: "Remove every candidate number outside %s.", + IssueRepeatedMember: "List each candidate number at most once within the duplicate group.", + IssueFewerThanTwoMembers: "Include at least two distinct candidate numbers, or omit the duplicate group.", + IssueCanonicalNonPositive: "Choose `canonical_candidate_number` from %s.", + IssueCanonicalUnknown: "Choose `canonical_candidate_number` from %s.", + IssueCanonicalNotMember: "Make `canonical_candidate_number` one of the `candidate_numbers` in the same duplicate group.", + IssueOverlappingMember: "Place each candidate number in at most one duplicate group.", } // Issue identifies an unsafe proposal category at its original response group @@ -77,7 +77,10 @@ func IssueDetails(issues []Issue) []string { // CorrectionDetails translates proposal issues into stable model-facing prose. // The response-local group ordinals help the model find the defective group in // the exact response appended to the correction request. -func CorrectionDetails(issues []Issue) ([]string, error) { +func CorrectionDetails(issues []Issue, numberRange CandidateNumberRange) ([]string, error) { + if err := validateCorrectionRange(numberRange); err != nil { + return nil, err + } groupsByCategory := make(map[IssueCategory][]int) seen := make(map[IssueCategory]map[int]struct{}) for _, issue := range issues { @@ -98,13 +101,22 @@ func CorrectionDetails(issues []Issue) ([]string, error) { } details := make([]string, 0, len(groupsByCategory)) + includeNamespaceReminder := false for _, category := range allIssueCategories { groups := groupsByCategory[category] if len(groups) == 0 { continue } sort.Ints(groups) - details = append(details, fmt.Sprintf("%s: %s", correctionGroupLabel(groups), issueCorrectionProse[category])) + prose := issueCorrectionProse[category] + if issueNeedsCandidateRange(category) { + prose = fmt.Sprintf(prose, candidateRangeDescription(numberRange)) + includeNamespaceReminder = true + } + details = append(details, fmt.Sprintf("%s: %s", correctionGroupLabel(groups), prose)) + } + if includeNamespaceReminder { + details = append(details, "Candidate numbers are the request-local `candidate_number` values in that range. Transcript unit `id` values and evidence `start_unit_id` and `end_unit_id` values are source positions, not candidate numbers.") } return details, nil } @@ -112,8 +124,8 @@ func CorrectionDetails(issues []Issue) ([]string, error) { // CorrectionGuidance builds one bounded request for a complete corrected // proposal. Additional details let a typed owner append a domain rule without // weakening or duplicating the shared protocol guidance. -func CorrectionGuidance(issues []Issue, additionalDetails ...string) (string, error) { - details, err := CorrectionDetails(issues) +func CorrectionGuidance(issues []Issue, numberRange CandidateNumberRange, additionalDetails ...string) (string, error) { + details, err := CorrectionDetails(issues, numberRange) if err != nil { return "", err } @@ -142,6 +154,26 @@ func CorrectionGuidance(issues []Issue, additionalDetails ...string) (string, er return guidance, nil } +func validateCorrectionRange(numberRange CandidateNumberRange) error { + if numberRange.First != 1 || numberRange.Last < 2 { + return fmt.Errorf("semantic reconciliation correction candidate-number range must start at 1 and include at least two candidates") + } + return nil +} + +func candidateRangeDescription(numberRange CandidateNumberRange) string { + return fmt.Sprintf("the inclusive candidate-number range %d through %d", numberRange.First, numberRange.Last) +} + +func issueNeedsCandidateRange(category IssueCategory) bool { + switch category { + case IssueMemberNonPositive, IssueMemberUnknown, IssueCanonicalNonPositive, IssueCanonicalUnknown: + return true + default: + return false + } +} + func correctionGroupLabel(groupIndexes []int) string { const maximumDisplayedGroups = 12 displayed := groupIndexes @@ -232,18 +264,18 @@ type assessedGroup struct { conflicting bool } -// Assess resolves request-local IDs through the retained preparation mapping -// and returns only deterministic, non-overlapping groups. +// Assess resolves request-local candidate numbers through the retained +// preparation mapping and returns only deterministic, non-overlapping groups. func (preparation Preparation) Assess(response ProposalResponse) Assessment { - positionsByID := make(map[int]int, len(preparation.mappings)) + positionsByNumber := make(map[int]int, len(preparation.mappings)) for _, mapping := range preparation.mappings { - positionsByID[mapping.CandidateID] = mapping.CandidatePosition + positionsByNumber[mapping.CandidateNumber] = mapping.CandidatePosition } groups := make([]assessedGroup, len(response.DuplicateGroups)) owners := make(map[int][]int) for groupIndex, proposal := range response.DuplicateGroups { - groups[groupIndex] = assessGroup(proposal, positionsByID) + groups[groupIndex] = assessGroup(proposal, positionsByNumber) if !groups[groupIndex].locallyValid { continue } @@ -283,17 +315,17 @@ func (preparation Preparation) Assess(response ProposalResponse) Assessment { return assessment } -func assessGroup(proposal DuplicateGroup, positionsByID map[int]int) assessedGroup { +func assessGroup(proposal DuplicateGroup, positionsByNumber map[int]int) assessedGroup { group := assessedGroup{} - seenIDs := make(map[int]struct{}, len(proposal.CandidateIDs)) - memberPositions := make(map[int]struct{}, len(proposal.CandidateIDs)) - for _, candidateID := range proposal.CandidateIDs { - if _, repeated := seenIDs[candidateID]; repeated { + seenNumbers := make(map[int]struct{}, len(proposal.CandidateNumbers)) + memberPositions := make(map[int]struct{}, len(proposal.CandidateNumbers)) + for _, candidateNumber := range proposal.CandidateNumbers { + if _, repeated := seenNumbers[candidateNumber]; repeated { group.issues = append(group.issues, IssueRepeatedMember) continue } - seenIDs[candidateID] = struct{}{} - position, category := resolveMember(candidateID, positionsByID) + seenNumbers[candidateNumber] = struct{}{} + position, category := resolveMember(candidateNumber, positionsByNumber) if category != "" { group.issues = append(group.issues, category) continue @@ -305,7 +337,7 @@ func assessGroup(proposal DuplicateGroup, positionsByID map[int]int) assessedGro group.issues = append(group.issues, IssueFewerThanTwoMembers) } - canonicalPosition, canonicalCategory := resolveCanonical(proposal.CanonicalCandidateID, positionsByID) + canonicalPosition, canonicalCategory := resolveCanonical(proposal.CanonicalCandidateNumber, positionsByNumber) if canonicalCategory != "" { group.issues = append(group.issues, canonicalCategory) } else { @@ -320,22 +352,22 @@ func assessGroup(proposal DuplicateGroup, positionsByID map[int]int) assessedGro return group } -func resolveMember(candidateID int, positionsByID map[int]int) (int, IssueCategory) { - if candidateID <= 0 { +func resolveMember(candidateNumber int, positionsByNumber map[int]int) (int, IssueCategory) { + if candidateNumber <= 0 { return 0, IssueMemberNonPositive } - position, exists := positionsByID[candidateID] + position, exists := positionsByNumber[candidateNumber] if !exists { return 0, IssueMemberUnknown } return position, "" } -func resolveCanonical(candidateID int, positionsByID map[int]int) (int, IssueCategory) { - if candidateID <= 0 { +func resolveCanonical(candidateNumber int, positionsByNumber map[int]int) (int, IssueCategory) { + if candidateNumber <= 0 { return 0, IssueCanonicalNonPositive } - position, exists := positionsByID[candidateID] + position, exists := positionsByNumber[candidateNumber] if !exists { return 0, IssueCanonicalUnknown } diff --git a/internal/framework/semanticreconcile/proposal_test.go b/internal/framework/semanticreconcile/proposal_test.go index 0e97efae..6eb40c70 100644 --- a/internal/framework/semanticreconcile/proposal_test.go +++ b/internal/framework/semanticreconcile/proposal_test.go @@ -12,8 +12,8 @@ import ( func TestAssessProducesAStableOriginalPositionPlan(t *testing.T) { preparation := proposalPreparation(t) response := ProposalResponse{DuplicateGroups: []DuplicateGroup{ - {CandidateIDs: []int{5, 4}, CanonicalCandidateID: 5}, - {CandidateIDs: []int{2, 1}, CanonicalCandidateID: 2}, + {CandidateNumbers: []int{5, 4}, CanonicalCandidateNumber: 5}, + {CandidateNumbers: []int{2, 1}, CanonicalCandidateNumber: 2}, }} assessment := preparation.Assess(response) want := []planGroupSnapshot{ @@ -28,8 +28,8 @@ func TestAssessProducesAStableOriginalPositionPlan(t *testing.T) { } reordered := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{ - {CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2}, - {CandidateIDs: []int{4, 5}, CanonicalCandidateID: 5}, + {CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 2}, + {CandidateNumbers: []int{4, 5}, CanonicalCandidateNumber: 5}, }}) if got := snapshotPlan(reordered.Plan()); !reflect.DeepEqual(got, want) { t.Fatalf("reordered plan = %#v, want %#v", got, want) @@ -53,6 +53,7 @@ func TestIssueDetailsPreservesIssueOrder(t *testing.T) { } func TestCorrectionGuidanceCoversEveryIssueCategoryWithoutExposingInternalLabels(t *testing.T) { + numberRange := CandidateNumberRange{First: 1, Last: 8} if len(issueCorrectionProse) != len(allIssueCategories) { t.Fatalf("correction prose entries = %d, categories = %d", len(issueCorrectionProse), len(allIssueCategories)) } @@ -69,15 +70,15 @@ func TestCorrectionGuidanceCoversEveryIssueCategoryWithoutExposingInternalLabels issues[index] = Issue{GroupIndex: index, Category: category} } - details, err := CorrectionDetails(issues) + details, err := CorrectionDetails(issues, numberRange) if err != nil { t.Fatalf("CorrectionDetails() error = %v", err) } - guidance, err := CorrectionGuidance(issues) + guidance, err := CorrectionGuidance(issues, numberRange) if err != nil { t.Fatalf("CorrectionGuidance() error = %v", err) } - if len(details) != len(allIssueCategories) || !strings.Contains(guidance, "Duplicate group 1") || !strings.Contains(guidance, "complete corrected JSON response") || len(guidance) > contracts.MaxNormalizeRetryCorrectionGuidanceBytes { + if len(details) != len(allIssueCategories)+1 || !strings.Contains(guidance, "Duplicate group 1") || !strings.Contains(guidance, "candidate-number range 1 through 8") || !strings.Contains(guidance, "start_unit_id") || !strings.Contains(guidance, "complete corrected JSON response") || len(guidance) > contracts.MaxNormalizeRetryCorrectionGuidanceBytes { t.Fatalf("correction details = %#v guidance = %q", details, guidance) } for _, category := range allIssueCategories { @@ -88,20 +89,21 @@ func TestCorrectionGuidanceCoversEveryIssueCategoryWithoutExposingInternalLabels } func TestCorrectionDetailsDeduplicatesAndBoundsAffectedGroupLists(t *testing.T) { + numberRange := CandidateNumberRange{First: 1, Last: 256} issues := make([]Issue, 0, 257) for group := 0; group < 256; group++ { issues = append(issues, Issue{GroupIndex: group, Category: IssueMemberUnknown}) } issues = append(issues, Issue{GroupIndex: 0, Category: IssueMemberUnknown}) - details, err := CorrectionDetails(issues) + details, err := CorrectionDetails(issues, numberRange) if err != nil { t.Fatalf("CorrectionDetails() error = %v", err) } - if len(details) != 1 || !strings.Contains(details[0], "additional affected group") || strings.Count(details[0], "Duplicate groups") != 1 { - t.Fatalf("CorrectionDetails() = %#v, want one bounded grouped detail", details) + if len(details) != 2 || !strings.Contains(details[0], "additional affected group") || strings.Count(details[0], "Duplicate groups") != 1 || !strings.Contains(details[1], "source positions") { + t.Fatalf("CorrectionDetails() = %#v, want one bounded grouped detail and one namespace reminder", details) } - guidance, err := CorrectionGuidance(issues) + guidance, err := CorrectionGuidance(issues, numberRange) if err != nil || len(guidance) > contracts.MaxNormalizeRetryCorrectionGuidanceBytes { t.Fatalf("CorrectionGuidance() = %q, %v", guidance, err) } @@ -112,10 +114,15 @@ func TestCorrectionDetailsRejectsUnknownOrInvalidIssues(t *testing.T) { {{GroupIndex: 0, Category: "future_unmapped_category"}}, {{GroupIndex: -1, Category: IssueMemberUnknown}}, } { - if details, err := CorrectionDetails(issues); err == nil || details != nil { + if details, err := CorrectionDetails(issues, CandidateNumberRange{First: 1, Last: 2}); err == nil || details != nil { t.Fatalf("CorrectionDetails(%#v) = %#v, %v; want fail-closed error", issues, details, err) } } + for _, numberRange := range []CandidateNumberRange{{}, {First: 0, Last: 2}, {First: 1, Last: 1}} { + if details, err := CorrectionDetails([]Issue{{GroupIndex: 0, Category: IssueMemberUnknown}}, numberRange); err == nil || details != nil { + t.Fatalf("CorrectionDetails(range %#v) = %#v, %v; want fail-closed error", numberRange, details, err) + } + } } func TestAssessRejectsEveryUnsafeLocalGroupShape(t *testing.T) { @@ -125,15 +132,15 @@ func TestAssessRejectsEveryUnsafeLocalGroupShape(t *testing.T) { group DuplicateGroup category IssueCategory }{ - {name: "zero member", group: DuplicateGroup{CandidateIDs: []int{0, 2}, CanonicalCandidateID: 2}, category: IssueMemberNonPositive}, - {name: "negative member", group: DuplicateGroup{CandidateIDs: []int{-1, 2}, CanonicalCandidateID: 2}, category: IssueMemberNonPositive}, - {name: "unknown member", group: DuplicateGroup{CandidateIDs: []int{99, 2}, CanonicalCandidateID: 2}, category: IssueMemberUnknown}, - {name: "repeated member", group: DuplicateGroup{CandidateIDs: []int{1, 1}, CanonicalCandidateID: 1}, category: IssueRepeatedMember}, - {name: "too small", group: DuplicateGroup{CandidateIDs: []int{1}, CanonicalCandidateID: 1}, category: IssueFewerThanTwoMembers}, - {name: "zero canonical", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 0}, category: IssueCanonicalNonPositive}, - {name: "negative canonical", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: -1}, category: IssueCanonicalNonPositive}, - {name: "unknown canonical", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 99}, category: IssueCanonicalUnknown}, - {name: "canonical not member", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 3}, category: IssueCanonicalNotMember}, + {name: "zero member", group: DuplicateGroup{CandidateNumbers: []int{0, 2}, CanonicalCandidateNumber: 2}, category: IssueMemberNonPositive}, + {name: "negative member", group: DuplicateGroup{CandidateNumbers: []int{-1, 2}, CanonicalCandidateNumber: 2}, category: IssueMemberNonPositive}, + {name: "unknown member", group: DuplicateGroup{CandidateNumbers: []int{99, 2}, CanonicalCandidateNumber: 2}, category: IssueMemberUnknown}, + {name: "repeated member", group: DuplicateGroup{CandidateNumbers: []int{1, 1}, CanonicalCandidateNumber: 1}, category: IssueRepeatedMember}, + {name: "too small", group: DuplicateGroup{CandidateNumbers: []int{1}, CanonicalCandidateNumber: 1}, category: IssueFewerThanTwoMembers}, + {name: "zero canonical", group: DuplicateGroup{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 0}, category: IssueCanonicalNonPositive}, + {name: "negative canonical", group: DuplicateGroup{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: -1}, category: IssueCanonicalNonPositive}, + {name: "unknown canonical", group: DuplicateGroup{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 99}, category: IssueCanonicalUnknown}, + {name: "canonical not member", group: DuplicateGroup{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 3}, category: IssueCanonicalNotMember}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -151,9 +158,9 @@ func TestAssessRejectsEveryUnsafeLocalGroupShape(t *testing.T) { func TestAssessDiscardsEveryOverlappingGroupAndRetainsIndependentGroups(t *testing.T) { preparation := proposalPreparation(t) assessment := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{ - {CandidateIDs: []int{1, 2}, CanonicalCandidateID: 1}, - {CandidateIDs: []int{2, 3}, CanonicalCandidateID: 2}, - {CandidateIDs: []int{4, 5}, CanonicalCandidateID: 5}, + {CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 1}, + {CandidateNumbers: []int{2, 3}, CanonicalCandidateNumber: 2}, + {CandidateNumbers: []int{4, 5}, CanonicalCandidateNumber: 5}, }}) wantPlan := []planGroupSnapshot{{members: []int{5, 6}, canonical: 6}} if got := snapshotPlan(assessment.Plan()); !reflect.DeepEqual(got, wantPlan) { @@ -168,8 +175,8 @@ func TestAssessDiscardsEveryOverlappingGroupAndRetainsIndependentGroups(t *testi } invalidAndSafe := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{ - {CandidateIDs: []int{1, 99}, CanonicalCandidateID: 1}, - {CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2}, + {CandidateNumbers: []int{1, 99}, CanonicalCandidateNumber: 1}, + {CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 2}, }}) wantPlan = []planGroupSnapshot{{members: []int{0, 2}, canonical: 2}} if got := snapshotPlan(invalidAndSafe.Plan()); !reflect.DeepEqual(got, wantPlan) { @@ -183,13 +190,13 @@ func TestAssessDiscardsEveryOverlappingGroupAndRetainsIndependentGroups(t *testi func TestAssessmentAccessorsAndInputsDoNotShareRetainedState(t *testing.T) { preparation := proposalPreparation(t) response := ProposalResponse{DuplicateGroups: []DuplicateGroup{ - {CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2}, + {CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 2}, }} assessment := preparation.Assess(response) want := snapshotPlan(assessment.Plan()) - response.DuplicateGroups[0].CandidateIDs[0] = 99 - response.DuplicateGroups[0].CanonicalCandidateID = 99 + response.DuplicateGroups[0].CandidateNumbers[0] = 99 + response.DuplicateGroups[0].CanonicalCandidateNumber = 99 plan := assessment.Plan() groups := plan.Groups() members := groups[0].MemberPositions() @@ -200,7 +207,7 @@ func TestAssessmentAccessorsAndInputsDoNotShareRetainedState(t *testing.T) { } invalid := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{ - CandidateIDs: []int{1, 99}, CanonicalCandidateID: 1, + CandidateNumbers: []int{1, 99}, CanonicalCandidateNumber: 1, }}}) issues := invalid.Issues() issues[0].GroupIndex = 99 diff --git a/internal/framework/semanticreconcile/schema_test.go b/internal/framework/semanticreconcile/schema_test.go index 757e6ddd..027ca083 100644 --- a/internal/framework/semanticreconcile/schema_test.go +++ b/internal/framework/semanticreconcile/schema_test.go @@ -44,20 +44,21 @@ func TestResponseSchemaAcceptsOnlyTheIntegerProposalShape(t *testing.T) { valid bool }{ {name: "empty proposal", value: map[string]any{"duplicate_groups": []any{}}, valid: true}, - {name: "valid group", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 1}}}, valid: true}, - {name: "semantic canonical mismatch", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 3}}}, valid: true}, + {name: "valid group", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 2}, "canonical_candidate_number": 1}}}, valid: true}, + {name: "semantic canonical mismatch", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 2}, "canonical_candidate_number": 3}}}, valid: true}, {name: "missing proposal", value: map[string]any{}, valid: false}, {name: "unknown top-level field", value: map[string]any{"duplicate_groups": []any{}, "extra": true}, valid: false}, - {name: "missing members", value: map[string]any{"duplicate_groups": []any{map[string]any{"canonical_candidate_id": 1}}}, valid: false}, - {name: "missing canonical", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}}}}, valid: false}, - {name: "unknown group field", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 1, "name": "replacement"}}}, valid: false}, - {name: "too few members", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1}, "canonical_candidate_id": 1}}}, valid: false}, - {name: "semantic repeated members", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 1}, "canonical_candidate_id": 1}}}, valid: true}, - {name: "zero member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{0, 1}, "canonical_candidate_id": 1}}}, valid: false}, - {name: "negative member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{-1, 1}, "canonical_candidate_id": 1}}}, valid: false}, - {name: "non-integer member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2.5}, "canonical_candidate_id": 1}}}, valid: false}, - {name: "zero canonical", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 0}}}, valid: false}, - {name: "contextual selectors", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 1, "source_refs": []any{}}}}, valid: false}, + {name: "missing members", value: map[string]any{"duplicate_groups": []any{map[string]any{"canonical_candidate_number": 1}}}, valid: false}, + {name: "missing canonical", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 2}}}}, valid: false}, + {name: "unknown group field", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 2}, "canonical_candidate_number": 1, "name": "replacement"}}}, valid: false}, + {name: "former candidate ID fields", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 1}}}, valid: false}, + {name: "too few members", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1}, "canonical_candidate_number": 1}}}, valid: false}, + {name: "semantic repeated members", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 1}, "canonical_candidate_number": 1}}}, valid: true}, + {name: "zero member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{0, 1}, "canonical_candidate_number": 1}}}, valid: false}, + {name: "negative member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{-1, 1}, "canonical_candidate_number": 1}}}, valid: false}, + {name: "non-integer member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 2.5}, "canonical_candidate_number": 1}}}, valid: false}, + {name: "zero canonical", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 2}, "canonical_candidate_number": 0}}}, valid: false}, + {name: "contextual selectors", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 2}, "canonical_candidate_number": 1, "source_refs": []any{}}}}, valid: false}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -72,7 +73,7 @@ func TestResponseSchemaAcceptsOnlyTheIntegerProposalShape(t *testing.T) { }) } - content := []byte(`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`) + content := []byte(`{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`) if err := validateAgainstSchema(content, schema.JSONSchema); err != nil { t.Fatal(err) } @@ -80,7 +81,7 @@ func TestResponseSchemaAcceptsOnlyTheIntegerProposalShape(t *testing.T) { if err := json.Unmarshal(content, &response); err != nil { t.Fatal(err) } - want := ProposalResponse{DuplicateGroups: []DuplicateGroup{{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2}}} + want := ProposalResponse{DuplicateGroups: []DuplicateGroup{{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 2}}} if !reflect.DeepEqual(response, want) { t.Fatalf("decoded response = %#v, want %#v", response, want) } diff --git a/internal/modules/dnd/normalize/itemregistry/normalizer.go b/internal/modules/dnd/normalize/itemregistry/normalizer.go index be820148..e2dec80b 100644 --- a/internal/modules/dnd/normalize/itemregistry/normalizer.go +++ b/internal/modules/dnd/normalize/itemregistry/normalizer.go @@ -156,7 +156,7 @@ func (n *Normalizer) invalidStructuredResult(value dnd.ItemRegistry, findings [] func retryResult(value dnd.ItemRegistry, findings, advisoryFindings []diagnostics.Finding, reconciliation semanticreconcile.Result, rejectedGroups int) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) { issues := reconciliation.Issues() operatorDetails := semanticreconcile.IssueDetails(issues) - correctionDetails, err := semanticreconcile.CorrectionDetails(issues) + correctionDetails, err := reconciliation.CorrectionDetails() if err != nil { return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("build semantic correction details: %w", err) } @@ -167,7 +167,7 @@ func retryResult(value dnd.ItemRegistry, findings, advisoryFindings []diagnostic correctionDetails = append(correctionDetails, currencyGuidance) additionalGuidance = append(additionalGuidance, currencyGuidance) } - correctionGuidance, err := semanticreconcile.CorrectionGuidance(issues, additionalGuidance...) + correctionGuidance, err := reconciliation.CorrectionGuidance(additionalGuidance...) if err != nil { return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("build semantic correction guidance: %w", err) } diff --git a/internal/modules/dnd/normalize/itemregistry/normalizer_test.go b/internal/modules/dnd/normalize/itemregistry/normalizer_test.go index 41ecd669..6af2fa52 100644 --- a/internal/modules/dnd/normalize/itemregistry/normalizer_test.go +++ b/internal/modules/dnd/normalize/itemregistry/normalizer_test.go @@ -122,7 +122,7 @@ func TestNormalizeAppliesSafeAliasProposal(t *testing.T) { {Name: "Compass of the Stars", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}, {Name: "Gold Pieces", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, }} - client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`} + client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`} result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc)) if err != nil || result.Retry != nil || len(result.Value.Items) != 2 { t.Fatalf("Normalize() = %#v, %v", result, err) @@ -159,7 +159,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) { {Name: "Gold Piece", SourceRefs: ref(20)}, {Name: "Gold Pieces", SourceRefs: ref(30)}, }, - response: `{"duplicate_groups":[{"candidate_ids":[1,2,3],"canonical_candidate_id":2}]}`, + response: `{"duplicate_groups":[{"candidate_numbers":[1,2,3],"canonical_candidate_number":2}]}`, wantNames: []string{"Gold Piece"}, wantRefCounts: []int{3}, reasonCode: ReasonCodeDuplicateItemCollapsed, @@ -171,7 +171,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) { {Name: "Gold Pieces", SourceRefs: ref(10)}, {Name: "Silver Pieces", SourceRefs: ref(20)}, }, - response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1}]}`, + response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":1}]}`, wantNames: []string{"Gold Pieces", "Silver Pieces"}, wantRefCounts: []int{1, 1}, wantRetry: true, @@ -184,7 +184,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) { {Name: "Gold Pieces", SourceRefs: ref(10)}, {Name: "Longsword", SourceRefs: ref(20)}, }, - response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1}]}`, + response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":1}]}`, wantNames: []string{"Gold Pieces", "Longsword"}, wantRefCounts: []int{1, 1}, wantRetry: true, @@ -197,7 +197,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) { {Name: "Star Compass", SourceRefs: ref(10)}, {Name: "Compass of the Stars", SourceRefs: ref(20)}, }, - response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`, + response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`, wantNames: []string{"Compass of the Stars"}, wantRefCounts: []int{2}, reasonCode: ReasonCodeDuplicateItemCollapsed, @@ -209,7 +209,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) { {Name: "Gold Pieces", SourceRefs: ref(10)}, {Name: "Longsword", SourceRefs: ref(20)}, }, - response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`, + response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`, wantNames: []string{"Gold Pieces", "Longsword"}, wantRefCounts: []int{1, 1}, wantRetry: true, @@ -253,7 +253,7 @@ func TestNormalizePreservesCandidatesForUnsafeProposalGroups(t *testing.T) { {Name: "Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}, {Name: "Rope", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, }} - client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1},{"candidate_ids":[2,3],"canonical_candidate_id":3},{"candidate_ids":[1,3],"canonical_candidate_id":99}]}`} + client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":1},{"candidate_numbers":[2,3],"canonical_candidate_number":3},{"candidate_numbers":[1,3],"canonical_candidate_number":99}]}`} result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc)) if err != nil || result.Retry == nil || len(result.Value.Items) != 3 || !strings.Contains(result.Retry.Message, "overlapping_member") || !strings.Contains(result.Retry.Message, "canonical_unknown") || strings.Contains(result.Retry.Message, "Star Compass") || len(result.Retry.Message) > 4096 { t.Fatalf("Normalize() = %#v, %v; want deterministic retry fallback", result, err) @@ -272,7 +272,7 @@ func TestNormalizeAppliesIndependentGroupAndCountsAllOmissions(t *testing.T) { {Name: "Silver Pieces", SourceRefs: ref(30)}, {Name: "Rope", SourceRefs: ref(10)}, }} - client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2},{"candidate_ids":[3,4],"canonical_candidate_id":3},{"candidate_ids":[5,99],"canonical_candidate_id":5}]}`} + client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2},{"candidate_numbers":[3,4],"canonical_candidate_number":3},{"candidate_numbers":[5,99],"canonical_candidate_number":5}]}`} result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc)) if err != nil || result.Retry == nil { t.Fatalf("Normalize() = %#v, %v; want retry with independently accepted output", result, err) @@ -289,7 +289,7 @@ func TestNormalizeAppliesIndependentGroupAndCountsAllOmissions(t *testing.T) { if !hasDiagnostic(result.Diagnostics, ReasonCodeDuplicateItemCollapsed, contracts.DiagnosticDispositionObservation) || !hasDiagnostic(result.Diagnostics, ReasonCodeItemSemanticProposalInvalid, contracts.DiagnosticDispositionAdvisory) { t.Fatalf("diagnostics = %#v, want accepted and guarded-group diagnostics", result.Diagnostics) } - if !strings.Contains(result.Retry.CorrectionGuidance, "aliases of the same denomination") || !strings.Contains(result.Retry.CorrectionGuidance, "do not combine currency with non-currency items") || !strings.Contains(result.Retry.CorrectionGuidance, "supplied candidate list") || strings.Contains(result.Retry.CorrectionGuidance, "member_unknown") || strings.Contains(result.Retry.CorrectionGuidance, ReasonCodeItemSemanticRetryProposalInvalid) { + if !strings.Contains(result.Retry.CorrectionGuidance, "aliases of the same denomination") || !strings.Contains(result.Retry.CorrectionGuidance, "do not combine currency with non-currency items") || !strings.Contains(result.Retry.CorrectionGuidance, "candidate-number range 1 through 5") || !strings.Contains(result.Retry.CorrectionGuidance, "source positions") || strings.Contains(result.Retry.CorrectionGuidance, "member_unknown") || strings.Contains(result.Retry.CorrectionGuidance, ReasonCodeItemSemanticRetryProposalInvalid) { t.Fatalf("correction guidance = %q, want shared and item-specific prose only", result.Retry.CorrectionGuidance) } if len(result.Retry.FallbackDiagnostics) != 1 || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "2 proposal group(s)") || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "preserved separately") || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "same denomination") { @@ -355,11 +355,11 @@ func TestRegisterPromptAssetsPreparesItemNormalizationPrompt(t *testing.T) { if err != nil { t.Fatal(err) } - prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "item-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"candidate_id":1,"label":"Rope","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}}) + prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "item-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidate_number_range":{"first":1,"last":1},"candidates":[{"candidate_number":1,"label":"Rope","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}}) if err != nil { t.Fatal(err) } - if prepared.OutputContract.SchemaPath != "semantic_reconciliation_llm.v1.json" || !strings.Contains(prepared.Messages[1].Content, "candidate_id") || !strings.Contains(prepared.Messages[1].Content, "integer") || !strings.Contains(prepared.Messages[2].Content, "currency denominations") || !strings.Contains(prepared.Messages[2].Content, "materially different item") || prepared.Messages[2].CacheControl == nil || prepared.Messages[2].CacheControl.Type != promptkit.CacheControlEphemeral { + if prepared.OutputContract.SchemaPath != "semantic_reconciliation_llm.v1.json" || !strings.Contains(prepared.Messages[1].Content, "candidate_number_range") || !strings.Contains(prepared.Messages[1].Content, "start_unit_id") || !strings.Contains(prepared.Messages[2].Content, "currency denominations") || !strings.Contains(prepared.Messages[2].Content, "materially different item") || !strings.Contains(prepared.Messages[2].Content, "canonical_candidate_number") || prepared.Messages[2].CacheControl == nil || prepared.Messages[2].CacheControl.Type != promptkit.CacheControlEphemeral { t.Fatalf("prepared prompt = %#v", prepared) } if prepared.Messages[4].CacheControl == nil || prepared.Messages[4].CacheControl.Type != promptkit.CacheControlEphemeral || !strings.Contains(prepared.Messages[3].Content, `"Rope"`) || strings.Contains(prepared.Messages[3].Content, `"windows"`) || !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, `"Rope"`) { diff --git a/internal/modules/dnd/normalize/locationregistry/normalizer.go b/internal/modules/dnd/normalize/locationregistry/normalizer.go index 6dceb131..13708e49 100644 --- a/internal/modules/dnd/normalize/locationregistry/normalizer.go +++ b/internal/modules/dnd/normalize/locationregistry/normalizer.go @@ -154,11 +154,11 @@ func (n *Normalizer) invalidStructuredResult(value dnd.LocationRegistry, finding func retryResult(value dnd.LocationRegistry, findings []diagnostics.Finding, reconciliation semanticreconcile.Result) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) { issues := reconciliation.Issues() - correctionDetails, err := semanticreconcile.CorrectionDetails(issues) + correctionDetails, err := reconciliation.CorrectionDetails() if err != nil { return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("build semantic correction details: %w", err) } - correctionGuidance, err := semanticreconcile.CorrectionGuidance(issues) + correctionGuidance, err := reconciliation.CorrectionGuidance() if err != nil { return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("build semantic correction guidance: %w", err) } diff --git a/internal/modules/dnd/normalize/locationregistry/normalizer_test.go b/internal/modules/dnd/normalize/locationregistry/normalizer_test.go index e884a781..470bf7b3 100644 --- a/internal/modules/dnd/normalize/locationregistry/normalizer_test.go +++ b/internal/modules/dnd/normalize/locationregistry/normalizer_test.go @@ -95,7 +95,7 @@ func BenchmarkExactDuplicateGroupsManyDistinct(b *testing.B) { } func TestNormalizeAppliesSafeAliasGroupAndUsesContextualInputs(t *testing.T) { - client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`} + client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`} doc := semanticDocument() input := dnd.LocationRegistry{Locations: []dnd.Location{ {Name: "Old Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, @@ -123,7 +123,7 @@ func TestNormalizeRejectsUnsafeAndOverlappingGroupsWithoutLosingCandidates(t *te {Name: "Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}, {Name: "Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, }} - client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1},{"candidate_ids":[2,3],"canonical_candidate_id":3}]}`} + client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":1},{"candidate_numbers":[2,3],"canonical_candidate_number":3}]}`} result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc)) if err != nil || result.Retry == nil || len(result.Value.Locations) != 3 || !strings.Contains(result.Retry.Message, "overlapping_member") { t.Fatalf("Normalize() = %#v, %v; want safe retry fallback", result, err) @@ -152,14 +152,14 @@ func TestReconciliationCandidatesKeepSameNameEvidenceDistinct(t *testing.T) { } var candidateInput struct { Candidates []struct { - CandidateID int `json:"candidate_id"` - Label string `json:"label"` + CandidateNumber int `json:"candidate_number"` + Label string `json:"label"` } `json:"candidates"` } if err := json.Unmarshal(preparation.Materials()["candidates"].Content, &candidateInput); err != nil { t.Fatal(err) } - if len(candidateInput.Candidates) != 2 || candidateInput.Candidates[0].CandidateID != 1 || candidateInput.Candidates[1].CandidateID != 2 || candidateInput.Candidates[0].Label != "The Tavern" || candidateInput.Candidates[1].Label != "The Tavern" { + if len(candidateInput.Candidates) != 2 || candidateInput.Candidates[0].CandidateNumber != 1 || candidateInput.Candidates[1].CandidateNumber != 2 || candidateInput.Candidates[0].Label != "The Tavern" || candidateInput.Candidates[1].Label != "The Tavern" { t.Fatalf("candidate input = %#v, want distinct integer handles for equal names", candidateInput) } } diff --git a/internal/modules/dnd/normalize/locationregistry/prompt_assets_test.go b/internal/modules/dnd/normalize/locationregistry/prompt_assets_test.go index ba54f9ac..09ce036d 100644 --- a/internal/modules/dnd/normalize/locationregistry/prompt_assets_test.go +++ b/internal/modules/dnd/normalize/locationregistry/prompt_assets_test.go @@ -28,11 +28,11 @@ func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) { if err != nil { t.Fatal(err) } - prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "location-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"candidate_id":1,"label":"The Tavern","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}}) + prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "location-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidate_number_range":{"first":1,"last":1},"candidates":[{"candidate_number":1,"label":"The Tavern","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}}) if err != nil { t.Fatal(err) } - if prepared.OutputContract.SchemaPath != "semantic_reconciliation_llm.v1.json" || !strings.Contains(prepared.Messages[1].Content, "candidate_id") || !strings.Contains(prepared.Messages[1].Content, "integer") || !strings.Contains(prepared.Messages[2].Content, "same physical place") || !strings.Contains(prepared.Messages[2].Content, "parent and child places") { + if prepared.OutputContract.SchemaPath != "semantic_reconciliation_llm.v1.json" || !strings.Contains(prepared.Messages[1].Content, "candidate_number_range") || !strings.Contains(prepared.Messages[1].Content, "start_unit_id") || !strings.Contains(prepared.Messages[2].Content, "same physical place") || !strings.Contains(prepared.Messages[2].Content, "parent and child places") || !strings.Contains(prepared.Messages[2].Content, "canonical_candidate_number") { t.Fatalf("prepared prompt = %#v", prepared) } for _, index := range []int{2, 4} { diff --git a/internal/modules/dnd/normalize/npcregistry/normalizer.go b/internal/modules/dnd/normalize/npcregistry/normalizer.go index c641313f..f5bbd8ab 100644 --- a/internal/modules/dnd/normalize/npcregistry/normalizer.go +++ b/internal/modules/dnd/normalize/npcregistry/normalizer.go @@ -153,11 +153,11 @@ func (n *Normalizer) invalidStructuredResult(value dnd.NPCRegistry, findings []d func retryResult(value dnd.NPCRegistry, findings []diagnostics.Finding, reconciliation semanticreconcile.Result) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) { issues := reconciliation.Issues() - correctionDetails, err := semanticreconcile.CorrectionDetails(issues) + correctionDetails, err := reconciliation.CorrectionDetails() if err != nil { return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("build semantic correction details: %w", err) } - correctionGuidance, err := semanticreconcile.CorrectionGuidance(issues) + correctionGuidance, err := reconciliation.CorrectionGuidance() if err != nil { return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("build semantic correction guidance: %w", err) } diff --git a/internal/modules/dnd/normalize/npcregistry/prompt_assets_test.go b/internal/modules/dnd/normalize/npcregistry/prompt_assets_test.go index a2f5290c..dbcbe82b 100644 --- a/internal/modules/dnd/normalize/npcregistry/prompt_assets_test.go +++ b/internal/modules/dnd/normalize/npcregistry/prompt_assets_test.go @@ -36,7 +36,7 @@ func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) { prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{ PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "normalize-test-profile", Inputs: map[string]promptkit.ArtifactRef{ - "candidates": promptkit.Inline(`{"candidates":[{"candidate_id":1,"label":"Mira","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), + "candidates": promptkit.Inline(`{"candidate_number_range":{"first":1,"last":1},"candidates":[{"candidate_number":1,"label":"Mira","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`), }, }) @@ -49,10 +49,10 @@ func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) { if prepared.Messages[0].Role != "system" { t.Fatalf("initial message role = %q, want system", prepared.Messages[0].Role) } - if !strings.Contains(prepared.Messages[1].Content, "candidate_id") || !strings.Contains(prepared.Messages[1].Content, "integer") { + if !strings.Contains(prepared.Messages[1].Content, "candidate_number") || !strings.Contains(prepared.Messages[1].Content, "integer") { t.Fatalf("protocol message = %q, want shared integer-handle protocol", prepared.Messages[1].Content) } - if !strings.Contains(prepared.Messages[2].Content, "same individual") || strings.Contains(prepared.Messages[2].Content, "source ranges") { + if !strings.Contains(prepared.Messages[2].Content, "same individual") || !strings.Contains(prepared.Messages[2].Content, "canonical_candidate_number") || strings.Contains(prepared.Messages[2].Content, "source ranges") { t.Fatalf("NPC policy message = %q, want domain distinctions without copied ranges", prepared.Messages[2].Content) } for _, index := range []int{2, 4} { diff --git a/internal/modules/dnd/normalize/npcregistry/semantic_normalizer_test.go b/internal/modules/dnd/normalize/npcregistry/semantic_normalizer_test.go index e4a9a6ee..5b66e985 100644 --- a/internal/modules/dnd/normalize/npcregistry/semantic_normalizer_test.go +++ b/internal/modules/dnd/normalize/npcregistry/semantic_normalizer_test.go @@ -35,7 +35,7 @@ func TestNormalizeSkipsSemanticCompletionWithoutTwoEligibleCandidates(t *testing } func TestNormalizeAppliesSafeProposalAndUsesPrivateInputs(t *testing.T) { - client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`} + client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`} normalizer := newNormalizer(t, client) doc := semanticDocument() input := dnd.NPCRegistry{NPCs: []dnd.NPC{ @@ -88,19 +88,19 @@ func TestNormalizeAppliesSafeProposalAndUsesPrivateInputs(t *testing.T) { } var visible struct { Candidates []struct { - CandidateID int `json:"candidate_id"` + CandidateNumber int `json:"candidate_number"` } `json:"candidates"` } if err := json.Unmarshal(completion.Inputs["candidates"].Content, &visible); err != nil { t.Fatalf("decode candidates: %v", err) } - if got := []int{visible.Candidates[0].CandidateID, visible.Candidates[1].CandidateID, visible.Candidates[2].CandidateID}; !reflect.DeepEqual(got, []int{1, 2, 3}) { - t.Fatalf("candidate IDs = %v, want contiguous request-local handles", got) + if got := []int{visible.Candidates[0].CandidateNumber, visible.Candidates[1].CandidateNumber, visible.Candidates[2].CandidateNumber}; !reflect.DeepEqual(got, []int{1, 2, 3}) { + t.Fatalf("candidate numbers = %v, want contiguous request-local handles", got) } } func TestNormalizeUnsafeProposalReturnsSafeRetryFallback(t *testing.T) { - client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2},{"candidate_ids":[3,99],"canonical_candidate_id":3}]}`} + client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2},{"candidate_numbers":[3,99],"canonical_candidate_number":3}]}`} normalizer := newNormalizer(t, client) doc := semanticDocument() input := dnd.NPCRegistry{NPCs: []dnd.NPC{ @@ -115,13 +115,13 @@ func TestNormalizeUnsafeProposalReturnsSafeRetryFallback(t *testing.T) { if result.Retry.ReasonCode != ReasonCodeNPCSemanticProposalInvalid || !strings.Contains(result.Retry.Message, "group 1: member_unknown") { t.Fatalf("retry = %#v, want bounded invalid-proposal diagnostics", result.Retry) } - if !strings.Contains(result.Retry.CorrectionGuidance, "Duplicate group 2") || !strings.Contains(result.Retry.CorrectionGuidance, "supplied candidate list") || strings.Contains(result.Retry.CorrectionGuidance, "member_unknown") || strings.Contains(result.Retry.CorrectionGuidance, ReasonCodeNPCSemanticProposalInvalid) { + if !strings.Contains(result.Retry.CorrectionGuidance, "Duplicate group 2") || !strings.Contains(result.Retry.CorrectionGuidance, "candidate-number range 1 through 3") || !strings.Contains(result.Retry.CorrectionGuidance, "source positions") || strings.Contains(result.Retry.CorrectionGuidance, "member_unknown") || strings.Contains(result.Retry.CorrectionGuidance, ReasonCodeNPCSemanticProposalInvalid) { t.Fatalf("correction guidance = %q, want contextual prose without internal labels", result.Retry.CorrectionGuidance) } if len(result.Value.NPCs) != 2 || result.Value.NPCs[0].Name != "Mira Thorn" || result.Value.NPCs[1].Name != "Captain Vale" { t.Fatalf("fallback NPCs = %#v, want independently safe group applied", result.Value.NPCs) } - if len(result.Retry.FallbackDiagnostics) != 1 || result.Retry.FallbackDiagnostics[0].ReasonCode != ReasonCodeNPCSemanticReconciliationExhausted || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "1 proposal group") || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "preserved separately") || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "supplied candidate list") { + if len(result.Retry.FallbackDiagnostics) != 1 || result.Retry.FallbackDiagnostics[0].ReasonCode != ReasonCodeNPCSemanticReconciliationExhausted || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "1 proposal group") || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "preserved separately") || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "candidate-number range 1 through 3") { t.Fatalf("fallback diagnostics = %#v, want exact omitted-group warning", result.Retry.FallbackDiagnostics) } } @@ -184,8 +184,8 @@ func TestNormalizeRedactsContextMaterialFailures(t *testing.T) { func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) { client := &recordingNPCNormalizerClient{responses: []string{ - `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2},{"candidate_ids":[3,99],"canonical_candidate_id":3}]}`, - `{"duplicate_groups":[{"candidate_ids":[1,3],"canonical_candidate_id":3}]}`, + `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2},{"candidate_numbers":[3,99],"canonical_candidate_number":3}]}`, + `{"duplicate_groups":[{"candidate_numbers":[1,3],"canonical_candidate_number":3}]}`, }} normalizer := newNormalizer(t, client) doc := semanticDocument() @@ -208,7 +208,7 @@ func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) { } func TestNormalizeRetainsRecordsWhenAProposalUsesAnUnknownHandle(t *testing.T) { - client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,99],"canonical_candidate_id":1}]}`} + client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,99],"canonical_candidate_number":1}]}`} normalizer := newNormalizer(t, client) doc := semanticDocument() input := dnd.NPCRegistry{NPCs: []dnd.NPC{ @@ -241,14 +241,14 @@ func TestReconciliationCandidatesKeepEqualContextualDescriptorsDistinct(t *testi } var candidateInput struct { Candidates []struct { - CandidateID int `json:"candidate_id"` - Label string `json:"label"` + CandidateNumber int `json:"candidate_number"` + Label string `json:"label"` } `json:"candidates"` } if err := json.Unmarshal(preparation.Materials()["candidates"].Content, &candidateInput); err != nil { t.Fatal(err) } - if len(candidateInput.Candidates) != 2 || candidateInput.Candidates[0].CandidateID != 1 || candidateInput.Candidates[1].CandidateID != 2 || candidateInput.Candidates[0].Label != "The Guard" || candidateInput.Candidates[1].Label != "The Guard" { + if len(candidateInput.Candidates) != 2 || candidateInput.Candidates[0].CandidateNumber != 1 || candidateInput.Candidates[1].CandidateNumber != 2 || candidateInput.Candidates[0].Label != "The Guard" || candidateInput.Candidates[1].Label != "The Guard" { t.Fatalf("candidate input = %#v, want distinct integer handles for equal descriptors", candidateInput) } } diff --git a/internal/modules/integration/dnd_npc_occurrences_runner_test.go b/internal/modules/integration/dnd_npc_occurrences_runner_test.go index 64a4d0e0..4972176e 100644 --- a/internal/modules/integration/dnd_npc_occurrences_runner_test.go +++ b/internal/modules/integration/dnd_npc_occurrences_runner_test.go @@ -297,7 +297,7 @@ func (client *semanticNPCOccurrenceClient) CompleteStructured(_ context.Context, } payload = map[string]any{"npcs": []any{map[string]any{"name": name, "source_refs": []any{map[string]int{"start_unit_id": client.npcCalls, "end_unit_id": client.npcCalls}}}}} case npcnormalize.PromptID: - content := []byte(`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1}]}`) + content := []byte(`{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":1}]}`) if err := json.Unmarshal(content, out); err != nil { return contracts.StructuredCompletionResponse{}, err } diff --git a/internal/modules/integration/dnd_npc_registry_runner_test.go b/internal/modules/integration/dnd_npc_registry_runner_test.go index 1b42a128..df5c0ec8 100644 --- a/internal/modules/integration/dnd_npc_registry_runner_test.go +++ b/internal/modules/integration/dnd_npc_registry_runner_test.go @@ -181,8 +181,8 @@ func TestProductionNPCNormalizationRetryUsesFinalSafeProposal(t *testing.T) { {Name: "Mira Thorn", SourceRefs: []npcProductionSourceRef{{StartUnitID: 2, EndUnitID: 2}}}, {Name: "Hooded Guard", SourceRefs: []npcProductionSourceRef{{StartUnitID: 3, EndUnitID: 3}}}, }} - partial := []byte(`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2},{"candidate_ids":[3,99],"canonical_candidate_id":3}]}`) - safe := []byte(`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`) + partial := []byte(`{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2},{"candidate_numbers":[3,99],"canonical_candidate_number":3}]}`) + safe := []byte(`{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`) for _, test := range []struct { name string