Clarify semantic reconciliation candidate numbers
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
Candidate material:
|
||||
Candidate material, including the exact valid candidate-number range:
|
||||
|
||||
{{ input "candidates" }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
270
docs/roadmap/semantic-reconciliation-candidate-numbers.md
Normal file
270
docs/roadmap/semantic-reconciliation-candidate-numbers.md
Normal file
@@ -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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ type Result struct {
|
||||
plan Plan
|
||||
issues []Issue
|
||||
discardedGroupCount int
|
||||
candidateNumberRange CandidateNumberRange
|
||||
candidateMappings []CandidateMapping
|
||||
modelCandidate *contracts.ModelCandidate
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -70,16 +70,24 @@ 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
|
||||
candidateNumberRange CandidateNumberRange
|
||||
mappings []CandidateMapping
|
||||
materials contracts.LLMInputSet
|
||||
}
|
||||
@@ -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,12 +120,13 @@ type sourceRange struct {
|
||||
}
|
||||
|
||||
type visibleCandidate struct {
|
||||
CandidateID int `json:"candidate_id"`
|
||||
CandidateNumber int `json:"candidate_number"`
|
||||
Label string `json:"label"`
|
||||
SourceRefs []sourceRange `json:"source_refs"`
|
||||
}
|
||||
|
||||
type candidateInput struct {
|
||||
CandidateNumberRange CandidateNumberRange `json:"candidate_number_range"`
|
||||
Candidates []visibleCandidate `json:"candidates"`
|
||||
}
|
||||
|
||||
@@ -168,15 +183,18 @@ 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,
|
||||
CandidateNumber: candidateNumber,
|
||||
Label: candidates[candidate.position].Label,
|
||||
SourceRefs: cloneSourceRanges(candidate.references),
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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} {
|
||||
|
||||
@@ -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"`
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user