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
|
Keep currency denominations and materially different item types separate. Keep
|
||||||
uncertain aliases separate. Do not infer an item property or uniqueness.
|
uncertain aliases separate. Do not infer an item property or uniqueness.
|
||||||
|
|
||||||
When selecting a canonical display name, choose one supplied candidate name
|
Set `canonical_candidate_number` to the supplied candidate number whose label
|
||||||
that is the clearest established designation.
|
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
|
Keep parent and child places separate, as well as similarly named places and
|
||||||
uncertain aliases.
|
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
|
even when their names are similar or their contextual descriptions are
|
||||||
identical.
|
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
|
over an abbreviation. Prefer an unadorned proper name over that name plus a
|
||||||
contextual class, role, title, or relationship descriptor unless the transcript
|
contextual class, role, title, or relationship descriptor unless the transcript
|
||||||
establishes the descriptor as part of the person's name. A longer display name
|
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" }}
|
{{ 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": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["candidate_ids", "canonical_candidate_id"],
|
"required": ["candidate_numbers", "canonical_candidate_number"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"candidate_ids": {
|
"candidate_numbers": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 2,
|
"minItems": 2,
|
||||||
"items": {
|
"items": {
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"minimum": 1
|
"minimum": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"canonical_candidate_id": {
|
"canonical_candidate_number": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1
|
"minimum": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,13 +266,16 @@ proposal to reconcile identity groups.
|
|||||||
|
|
||||||
The three registry normalizers instantiate the domain-neutral
|
The three registry normalizers instantiate the domain-neutral
|
||||||
`internal/framework/semanticreconcile` engine with default bounds. Each
|
`internal/framework/semanticreconcile` engine with default bounds. Each
|
||||||
eligible candidate receives a contiguous, one-based `candidate_id` for that
|
eligible candidate receives a contiguous, one-based `candidate_number` for that
|
||||||
request. The model sees that handle, the candidate label and source-free
|
request, and candidate material declares the exact inclusive range. The model
|
||||||
evidence ranges, plus bounded transcript windows; it returns only duplicate
|
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
|
groups of supplied handles and one supplied canonical handle per group. It
|
||||||
never returns names, evidence, durable IDs, or replacement records. Identical
|
never returns names, evidence, durable IDs, or replacement records. Identical
|
||||||
labels and evidence remain independently selectable because their handles are
|
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
|
The generic core owns the mandatory handle protocol, candidate and transcript
|
||||||
presentation, the private response schema, source-reference validation,
|
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
|
into contextual candidates and owned typed record envelopes, supplies its
|
||||||
chosen prompt identity and resolved LLM profile, and constructs an engine with
|
chosen prompt identity and resolved LLM profile, and constructs an engine with
|
||||||
explicit limits. The core filters invalid evidence, assigns contiguous
|
explicit limits. The core filters invalid evidence, assigns contiguous
|
||||||
request-local integer handles, renders bounded candidate and transcript
|
one-based request-local candidate numbers, renders bounded candidate and
|
||||||
materials, invokes the structured-completion boundary, and assesses the
|
transcript materials, invokes the structured-completion boundary, and assesses
|
||||||
returned duplicate groups into a stable non-overlapping plan.
|
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
|
The normalizer then applies that plan through a typed `ApplicationPolicy`. The
|
||||||
core preserves ungrouped records, contribution order, and provenance while the
|
core preserves ungrouped records, contribution order, and provenance while the
|
||||||
artifact family owns group guards, field and evidence consolidation, durable
|
artifact family owns group guards, field and evidence consolidation, durable
|
||||||
ID derivation, retry and fallback presentation, classified diagnostics, and postconditions.
|
ID derivation, retry and fallback presentation, classified diagnostics, and
|
||||||
Request-local handles do not enter the typed value or durable artifact. Fewer
|
postconditions.
|
||||||
than two eligible candidates skips model invocation; exceeding a candidate or
|
These candidate numbers are the concrete private representation of ADR-0013's
|
||||||
combined-material bound preserves the deterministic result under the family's
|
request-local handles; they do not enter the typed value or durable artifact.
|
||||||
fallback policy. Provider, transport, cancellation, and context-construction
|
Fewer than two eligible candidates skips model invocation; exceeding a
|
||||||
failures remain execution errors.
|
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
|
When the engine actually makes a proposal call, its typed result carries the
|
||||||
owned exact proposal response under the same correction contract as other
|
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) {
|
t.Run("feedback retry", func(t *testing.T) {
|
||||||
const (
|
const (
|
||||||
defective = `{"duplicate_groups":[{"candidate_ids":[1,99],"canonical_candidate_id":1}]}`
|
defective = `{"duplicate_groups":[{"candidate_numbers":[1,99],"canonical_candidate_number":1}]}`
|
||||||
guidance = "Use only candidate IDs from the supplied candidate list. Return one complete corrected response."
|
guidance = "Use only candidate numbers from the supplied candidate list. Return one complete corrected response."
|
||||||
)
|
)
|
||||||
var observed *contracts.SemanticCorrection
|
var observed *contracts.SemanticCorrection
|
||||||
terminal, err := runProducerAttempts(context.Background(), producerAttemptConfig{Retries: 1, Policy: DefaultValidationPolicy()}, func(_ context.Context, request producerAttemptRequest) (producerAttemptOutput, error) {
|
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) {
|
func TestRunnerForwardsModuleRequestedNormalizeCorrection(t *testing.T) {
|
||||||
const (
|
const (
|
||||||
defective = `{"duplicate_groups":[{"candidate_ids":[1,99],"canonical_candidate_id":1}]}`
|
defective = `{"duplicate_groups":[{"candidate_numbers":[1,99],"canonical_candidate_number":1}]}`
|
||||||
guidance = "Duplicate group 1 must use only supplied candidate IDs. Return one complete corrected response."
|
guidance = "Duplicate group 1 must use only supplied candidate numbers. Return one complete corrected response."
|
||||||
)
|
)
|
||||||
prepared := preparedAttemptDebugPipeline(t)
|
prepared := preparedAttemptDebugPipeline(t)
|
||||||
lane := &prepared.Steps[0].lanes[0]
|
lane := &prepared.Steps[0].lanes[0]
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func TestRegisterAssetsPreparesGenericPromptOffline(t *testing.T) {
|
|||||||
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||||
PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "semantic-reconciliation-test",
|
PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "semantic-reconciliation-test",
|
||||||
Inputs: map[string]promptkit.ArtifactRef{
|
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."}]}]}`),
|
"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
|
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) {
|
if !strings.Contains(protocol, requirement) {
|
||||||
t.Fatalf("protocol message = %q, want requirement %q", 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)
|
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)
|
t.Fatalf("transcript message = %q, want only transcript windows", prepared.Messages[4].Content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,12 +65,13 @@ const (
|
|||||||
|
|
||||||
// Result owns the safe plan and neutral diagnostics from one call.
|
// Result owns the safe plan and neutral diagnostics from one call.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
disposition ResultDisposition
|
disposition ResultDisposition
|
||||||
plan Plan
|
plan Plan
|
||||||
issues []Issue
|
issues []Issue
|
||||||
discardedGroupCount int
|
discardedGroupCount int
|
||||||
candidateMappings []CandidateMapping
|
candidateNumberRange CandidateNumberRange
|
||||||
modelCandidate *contracts.ModelCandidate
|
candidateMappings []CandidateMapping
|
||||||
|
modelCandidate *contracts.ModelCandidate
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disposition returns the classified outcome.
|
// Disposition returns the classified outcome.
|
||||||
@@ -85,11 +86,27 @@ func (result Result) Issues() []Issue { return append([]Issue(nil), result.issue
|
|||||||
// DiscardedGroupCount returns the number of excluded proposal groups.
|
// DiscardedGroupCount returns the number of excluded proposal groups.
|
||||||
func (result Result) DiscardedGroupCount() int { return result.discardedGroupCount }
|
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 {
|
func (result Result) CandidateMappings() []CandidateMapping {
|
||||||
return append([]CandidateMapping(nil), result.candidateMappings...)
|
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
|
// ModelCandidate returns an owned copy of the proposal response when a model
|
||||||
// completion produced this result.
|
// completion produced this result.
|
||||||
func (result Result) ModelCandidate() *contracts.ModelCandidate {
|
func (result Result) ModelCandidate() *contracts.ModelCandidate {
|
||||||
@@ -158,7 +175,10 @@ func (engine *Engine) Reconcile(ctx context.Context, request Request) (Result, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("semantic reconciliation %q: prepare materials: %w", request.StageName, err)
|
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() {
|
switch preparation.Disposition() {
|
||||||
case InsufficientCandidates:
|
case InsufficientCandidates:
|
||||||
result.disposition = SkippedInsufficientCandidates
|
result.disposition = SkippedInsufficientCandidates
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestNewEngineValidatesConstruction(t *testing.T) {
|
|||||||
|
|
||||||
func TestEnginePropagatesRequestAndAssessesResponse(t *testing.T) {
|
func TestEnginePropagatesRequestAndAssessesResponse(t *testing.T) {
|
||||||
client := &recordingReconciliationClient{responses: []ProposalResponse{{DuplicateGroups: []DuplicateGroup{{
|
client := &recordingReconciliationClient{responses: []ProposalResponse{{DuplicateGroups: []DuplicateGroup{{
|
||||||
CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2,
|
CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 2,
|
||||||
}}}}}
|
}}}}}
|
||||||
engine := newTestEngine(t, client, DefaultLimits())
|
engine := newTestEngine(t, client, DefaultLimits())
|
||||||
request := readyEngineRequest()
|
request := readyEngineRequest()
|
||||||
@@ -69,6 +69,9 @@ func TestEnginePropagatesRequestAndAssessesResponse(t *testing.T) {
|
|||||||
if result.Disposition() != Complete || result.DiscardedGroupCount() != 0 || len(result.Issues()) != 0 {
|
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())
|
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()
|
groups := result.Plan().Groups()
|
||||||
if len(groups) != 1 || !reflect.DeepEqual(groups[0].MemberPositions(), []int{0, 1}) || groups[0].CanonicalPosition() != 1 {
|
if len(groups) != 1 || !reflect.DeepEqual(groups[0].MemberPositions(), []int{0, 1}) || groups[0].CanonicalPosition() != 1 {
|
||||||
t.Fatalf("safe plan = %#v", groups)
|
t.Fatalf("safe plan = %#v", groups)
|
||||||
@@ -105,7 +108,7 @@ func TestEngineClassifiesSemanticAndTransportOutcomes(t *testing.T) {
|
|||||||
wantError error
|
wantError error
|
||||||
}{
|
}{
|
||||||
{name: "empty groups complete", response: ProposalResponse{DuplicateGroups: []DuplicateGroup{}}, want: Complete, wantCandidate: true},
|
{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: "invalid structured output retryable", completion: fmt.Errorf("decode response: %w", contracts.ErrInvalidStructuredOutput), want: RetryableInvalidStructuredOutput},
|
||||||
{name: "transport failure", completion: transportErr, wantError: transportErr},
|
{name: "transport failure", completion: transportErr, wantError: transportErr},
|
||||||
}
|
}
|
||||||
@@ -213,8 +216,8 @@ func TestEngineRejectsInvalidInvocationAndHonorsCancellation(t *testing.T) {
|
|||||||
func TestEngineCallsAreIndependentAndResultsAreOwned(t *testing.T) {
|
func TestEngineCallsAreIndependentAndResultsAreOwned(t *testing.T) {
|
||||||
client := &recordingReconciliationClient{responses: []ProposalResponse{
|
client := &recordingReconciliationClient{responses: []ProposalResponse{
|
||||||
{DuplicateGroups: []DuplicateGroup{
|
{DuplicateGroups: []DuplicateGroup{
|
||||||
{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 1},
|
{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 1},
|
||||||
{CandidateIDs: []int{2, 99}, CanonicalCandidateID: 2},
|
{CandidateNumbers: []int{2, 99}, CanonicalCandidateNumber: 2},
|
||||||
}},
|
}},
|
||||||
{DuplicateGroups: []DuplicateGroup{}},
|
{DuplicateGroups: []DuplicateGroup{}},
|
||||||
}}
|
}}
|
||||||
@@ -280,7 +283,7 @@ func cloneProposalResponse(response ProposalResponse) ProposalResponse {
|
|||||||
cloned := ProposalResponse{DuplicateGroups: make([]DuplicateGroup, len(response.DuplicateGroups))}
|
cloned := ProposalResponse{DuplicateGroups: make([]DuplicateGroup, len(response.DuplicateGroups))}
|
||||||
for index, group := range response.DuplicateGroups {
|
for index, group := range response.DuplicateGroups {
|
||||||
cloned.DuplicateGroups[index] = DuplicateGroup{
|
cloned.DuplicateGroups[index] = DuplicateGroup{
|
||||||
CandidateIDs: append([]int(nil), group.CandidateIDs...), CanonicalCandidateID: group.CanonicalCandidateID,
|
CandidateNumbers: append([]int(nil), group.CandidateNumbers...), CanonicalCandidateNumber: group.CanonicalCandidateNumber,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cloned
|
return cloned
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Policy identifies the framework-owned reconciliation and assessment rules.
|
// 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 _ contracts.ManifestMetadataProvider = (*Engine)(nil)
|
||||||
var _ pipeline.CheckpointFingerprintProvider = (*Engine)(nil)
|
var _ pipeline.CheckpointFingerprintProvider = (*Engine)(nil)
|
||||||
|
|||||||
@@ -70,18 +70,26 @@ const (
|
|||||||
LimitExceeded
|
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.
|
// corresponding zero-based position in the caller's candidate slice.
|
||||||
type CandidateMapping struct {
|
type CandidateMapping struct {
|
||||||
CandidateID int
|
CandidateNumber int
|
||||||
CandidatePosition 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.
|
// Preparation owns the visible candidate mapping and prompt materials.
|
||||||
type Preparation struct {
|
type Preparation struct {
|
||||||
disposition Disposition
|
disposition Disposition
|
||||||
mappings []CandidateMapping
|
candidateNumberRange CandidateNumberRange
|
||||||
materials contracts.LLMInputSet
|
mappings []CandidateMapping
|
||||||
|
materials contracts.LLMInputSet
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disposition returns the preparation outcome.
|
// Disposition returns the preparation outcome.
|
||||||
@@ -94,6 +102,12 @@ func (preparation Preparation) CandidateMappings() []CandidateMapping {
|
|||||||
return append([]CandidateMapping(nil), preparation.mappings...)
|
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.
|
// Materials returns independently owned candidate and transcript materials.
|
||||||
// It is empty unless Disposition returns Ready.
|
// It is empty unless Disposition returns Ready.
|
||||||
func (preparation Preparation) Materials() contracts.LLMInputSet {
|
func (preparation Preparation) Materials() contracts.LLMInputSet {
|
||||||
@@ -106,13 +120,14 @@ type sourceRange struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type visibleCandidate struct {
|
type visibleCandidate struct {
|
||||||
CandidateID int `json:"candidate_id"`
|
CandidateNumber int `json:"candidate_number"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
SourceRefs []sourceRange `json:"source_refs"`
|
SourceRefs []sourceRange `json:"source_refs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type candidateInput struct {
|
type candidateInput struct {
|
||||||
Candidates []visibleCandidate `json:"candidates"`
|
CandidateNumberRange CandidateNumberRange `json:"candidate_number_range"`
|
||||||
|
Candidates []visibleCandidate `json:"candidates"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type transcriptInput struct {
|
type transcriptInput struct {
|
||||||
@@ -168,17 +183,20 @@ func Prepare(document *source.SourceDocument, candidates []Candidate, limits Lim
|
|||||||
disposition: InsufficientCandidates,
|
disposition: InsufficientCandidates,
|
||||||
mappings: make([]CandidateMapping, len(prepared)),
|
mappings: make([]CandidateMapping, len(prepared)),
|
||||||
}
|
}
|
||||||
|
if len(prepared) > 0 {
|
||||||
|
result.candidateNumberRange = CandidateNumberRange{First: 1, Last: len(prepared)}
|
||||||
|
}
|
||||||
views := make([]visibleCandidate, len(prepared))
|
views := make([]visibleCandidate, len(prepared))
|
||||||
for index, candidate := range prepared {
|
for index, candidate := range prepared {
|
||||||
candidateID := index + 1
|
candidateNumber := index + 1
|
||||||
result.mappings[index] = CandidateMapping{
|
result.mappings[index] = CandidateMapping{
|
||||||
CandidateID: candidateID,
|
CandidateNumber: candidateNumber,
|
||||||
CandidatePosition: candidate.position,
|
CandidatePosition: candidate.position,
|
||||||
}
|
}
|
||||||
views[index] = visibleCandidate{
|
views[index] = visibleCandidate{
|
||||||
CandidateID: candidateID,
|
CandidateNumber: candidateNumber,
|
||||||
Label: candidates[candidate.position].Label,
|
Label: candidates[candidate.position].Label,
|
||||||
SourceRefs: cloneSourceRanges(candidate.references),
|
SourceRefs: cloneSourceRanges(candidate.references),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(prepared) < 2 {
|
if len(prepared) < 2 {
|
||||||
@@ -189,7 +207,7 @@ func Prepare(document *source.SourceDocument, candidates []Candidate, limits Lim
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
candidateContent, withinLimit, err := marshalCandidateInput(views, limits.MaximumMaterialBytes)
|
candidateContent, withinLimit, err := marshalCandidateInput(result.candidateNumberRange, views, limits.MaximumMaterialBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Preparation{}, fmt.Errorf("prepare semantic reconciliation: encode candidate material: %w", err)
|
return Preparation{}, fmt.Errorf("prepare semantic reconciliation: encode candidate material: %w", err)
|
||||||
}
|
}
|
||||||
@@ -302,10 +320,14 @@ func coalesceIntervals(intervals []sourceInterval) []sourceInterval {
|
|||||||
return coalesced
|
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))
|
content := make([]byte, 0, min(maximumBytes, 4096))
|
||||||
|
encodedRange, err := json.Marshal(numberRange)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
var withinLimit bool
|
var withinLimit bool
|
||||||
content, withinLimit = appendWithinLimit(content, maximumBytes, []byte(`{"candidates":[`))
|
content, withinLimit = appendWithinLimit(content, maximumBytes, []byte(`{"candidate_number_range":`), encodedRange, []byte(`,"candidates":[`))
|
||||||
if !withinLimit {
|
if !withinLimit {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,11 +47,14 @@ func TestPrepareBuildsContiguousCandidatesAndOwnedSourceContext(t *testing.T) {
|
|||||||
t.Fatalf("Prepare() mutated candidates: %#v", candidates)
|
t.Fatalf("Prepare() mutated candidates: %#v", candidates)
|
||||||
}
|
}
|
||||||
if got, want := preparation.CandidateMappings(), []CandidateMapping{
|
if got, want := preparation.CandidateMappings(), []CandidateMapping{
|
||||||
{CandidateID: 1, CandidatePosition: 0},
|
{CandidateNumber: 1, CandidatePosition: 0},
|
||||||
{CandidateID: 2, CandidatePosition: 1},
|
{CandidateNumber: 2, CandidatePosition: 1},
|
||||||
}; !reflect.DeepEqual(got, want) {
|
}; !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("CandidateMappings() = %#v, want %#v", 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()
|
materials := preparation.Materials()
|
||||||
if len(materials) != 2 {
|
if len(materials) != 2 {
|
||||||
@@ -79,12 +82,15 @@ func TestPrepareBuildsContiguousCandidatesAndOwnedSourceContext(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
wantCandidates := []visibleCandidate{
|
wantCandidates := []visibleCandidate{
|
||||||
{CandidateID: 1, 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}}},
|
||||||
{CandidateID: 2, 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) {
|
if !reflect.DeepEqual(candidatePayload.Candidates, wantCandidates) {
|
||||||
t.Fatalf("candidate payload = %#v, want %#v", 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 {
|
var candidateObjects struct {
|
||||||
Candidates []map[string]json.RawMessage `json:"candidates"`
|
Candidates []map[string]json.RawMessage `json:"candidates"`
|
||||||
}
|
}
|
||||||
@@ -92,12 +98,12 @@ func TestPrepareBuildsContiguousCandidatesAndOwnedSourceContext(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, candidate := range candidateObjects.Candidates {
|
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)
|
t.Fatalf("model-facing candidate fields = %#v", candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
combined := string(materials[candidateInputName].Content) + string(materials[transcriptInputName].Content)
|
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) {
|
if strings.Contains(combined, forbidden) {
|
||||||
t.Fatalf("model material leaked %q: %s", forbidden, combined)
|
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)
|
t.Fatalf("Prepare() disposition = %v, error = %v", preparation.Disposition(), err)
|
||||||
}
|
}
|
||||||
if got, want := preparation.CandidateMappings(), []CandidateMapping{
|
if got, want := preparation.CandidateMappings(), []CandidateMapping{
|
||||||
{CandidateID: 1, CandidatePosition: 0},
|
{CandidateNumber: 1, CandidatePosition: 0},
|
||||||
{CandidateID: 2, CandidatePosition: 1},
|
{CandidateNumber: 2, CandidatePosition: 1},
|
||||||
}; !reflect.DeepEqual(got, want) {
|
}; !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("CandidateMappings() = %#v, want %#v", 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
|
var transcript transcriptInput
|
||||||
if err := json.Unmarshal(preparation.Materials()[transcriptInputName].Content, &transcript); err != nil {
|
if err := json.Unmarshal(preparation.Materials()[transcriptInputName].Content, &transcript); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -208,7 +217,7 @@ func TestPrepareFiltersUnsafeCandidatesAndCoalescesAdjacentWindows(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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())
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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())
|
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"`
|
DuplicateGroups []DuplicateGroup `json:"duplicate_groups"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DuplicateGroup proposes supplied request-local candidate IDs that may denote
|
// DuplicateGroup proposes supplied request-local candidate numbers that may
|
||||||
// one entity and identifies one supplied member as canonical.
|
// denote one entity and identifies one supplied member as canonical.
|
||||||
type DuplicateGroup struct {
|
type DuplicateGroup struct {
|
||||||
CandidateIDs []int `json:"candidate_ids"`
|
CandidateNumbers []int `json:"candidate_numbers"`
|
||||||
CanonicalCandidateID int `json:"canonical_candidate_id"`
|
CanonicalCandidateNumber int `json:"canonical_candidate_number"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssueCategory identifies one stable proposal safety failure.
|
// IssueCategory identifies one stable proposal safety failure.
|
||||||
@@ -47,14 +47,14 @@ var allIssueCategories = []IssueCategory{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var issueCorrectionProse = map[IssueCategory]string{
|
var issueCorrectionProse = map[IssueCategory]string{
|
||||||
IssueMemberNonPositive: "Use only positive candidate IDs from the supplied candidate list.",
|
IssueMemberNonPositive: "Use only candidate numbers in %s.",
|
||||||
IssueMemberUnknown: "Remove every candidate ID that is not present in the supplied candidate list.",
|
IssueMemberUnknown: "Remove every candidate number outside %s.",
|
||||||
IssueRepeatedMember: "List each candidate ID at most once within the duplicate group.",
|
IssueRepeatedMember: "List each candidate number at most once within the duplicate group.",
|
||||||
IssueFewerThanTwoMembers: "Include at least two distinct candidate IDs, or omit the duplicate group.",
|
IssueFewerThanTwoMembers: "Include at least two distinct candidate numbers, or omit the duplicate group.",
|
||||||
IssueCanonicalNonPositive: "Choose a positive canonical_candidate_id from the supplied candidate list.",
|
IssueCanonicalNonPositive: "Choose `canonical_candidate_number` from %s.",
|
||||||
IssueCanonicalUnknown: "Choose canonical_candidate_id from the supplied candidate list.",
|
IssueCanonicalUnknown: "Choose `canonical_candidate_number` from %s.",
|
||||||
IssueCanonicalNotMember: "Make canonical_candidate_id one of the candidate_ids in the same duplicate group.",
|
IssueCanonicalNotMember: "Make `canonical_candidate_number` one of the `candidate_numbers` in the same duplicate group.",
|
||||||
IssueOverlappingMember: "Place each candidate ID in at most one duplicate group.",
|
IssueOverlappingMember: "Place each candidate number in at most one duplicate group.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue identifies an unsafe proposal category at its original response 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.
|
// CorrectionDetails translates proposal issues into stable model-facing prose.
|
||||||
// The response-local group ordinals help the model find the defective group in
|
// The response-local group ordinals help the model find the defective group in
|
||||||
// the exact response appended to the correction request.
|
// 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)
|
groupsByCategory := make(map[IssueCategory][]int)
|
||||||
seen := make(map[IssueCategory]map[int]struct{})
|
seen := make(map[IssueCategory]map[int]struct{})
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
@@ -98,13 +101,22 @@ func CorrectionDetails(issues []Issue) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
details := make([]string, 0, len(groupsByCategory))
|
details := make([]string, 0, len(groupsByCategory))
|
||||||
|
includeNamespaceReminder := false
|
||||||
for _, category := range allIssueCategories {
|
for _, category := range allIssueCategories {
|
||||||
groups := groupsByCategory[category]
|
groups := groupsByCategory[category]
|
||||||
if len(groups) == 0 {
|
if len(groups) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sort.Ints(groups)
|
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
|
return details, nil
|
||||||
}
|
}
|
||||||
@@ -112,8 +124,8 @@ func CorrectionDetails(issues []Issue) ([]string, error) {
|
|||||||
// CorrectionGuidance builds one bounded request for a complete corrected
|
// CorrectionGuidance builds one bounded request for a complete corrected
|
||||||
// proposal. Additional details let a typed owner append a domain rule without
|
// proposal. Additional details let a typed owner append a domain rule without
|
||||||
// weakening or duplicating the shared protocol guidance.
|
// weakening or duplicating the shared protocol guidance.
|
||||||
func CorrectionGuidance(issues []Issue, additionalDetails ...string) (string, error) {
|
func CorrectionGuidance(issues []Issue, numberRange CandidateNumberRange, additionalDetails ...string) (string, error) {
|
||||||
details, err := CorrectionDetails(issues)
|
details, err := CorrectionDetails(issues, numberRange)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -142,6 +154,26 @@ func CorrectionGuidance(issues []Issue, additionalDetails ...string) (string, er
|
|||||||
return guidance, nil
|
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 {
|
func correctionGroupLabel(groupIndexes []int) string {
|
||||||
const maximumDisplayedGroups = 12
|
const maximumDisplayedGroups = 12
|
||||||
displayed := groupIndexes
|
displayed := groupIndexes
|
||||||
@@ -232,18 +264,18 @@ type assessedGroup struct {
|
|||||||
conflicting bool
|
conflicting bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assess resolves request-local IDs through the retained preparation mapping
|
// Assess resolves request-local candidate numbers through the retained
|
||||||
// and returns only deterministic, non-overlapping groups.
|
// preparation mapping and returns only deterministic, non-overlapping groups.
|
||||||
func (preparation Preparation) Assess(response ProposalResponse) Assessment {
|
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 {
|
for _, mapping := range preparation.mappings {
|
||||||
positionsByID[mapping.CandidateID] = mapping.CandidatePosition
|
positionsByNumber[mapping.CandidateNumber] = mapping.CandidatePosition
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := make([]assessedGroup, len(response.DuplicateGroups))
|
groups := make([]assessedGroup, len(response.DuplicateGroups))
|
||||||
owners := make(map[int][]int)
|
owners := make(map[int][]int)
|
||||||
for groupIndex, proposal := range response.DuplicateGroups {
|
for groupIndex, proposal := range response.DuplicateGroups {
|
||||||
groups[groupIndex] = assessGroup(proposal, positionsByID)
|
groups[groupIndex] = assessGroup(proposal, positionsByNumber)
|
||||||
if !groups[groupIndex].locallyValid {
|
if !groups[groupIndex].locallyValid {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -283,17 +315,17 @@ func (preparation Preparation) Assess(response ProposalResponse) Assessment {
|
|||||||
return assessment
|
return assessment
|
||||||
}
|
}
|
||||||
|
|
||||||
func assessGroup(proposal DuplicateGroup, positionsByID map[int]int) assessedGroup {
|
func assessGroup(proposal DuplicateGroup, positionsByNumber map[int]int) assessedGroup {
|
||||||
group := assessedGroup{}
|
group := assessedGroup{}
|
||||||
seenIDs := make(map[int]struct{}, len(proposal.CandidateIDs))
|
seenNumbers := make(map[int]struct{}, len(proposal.CandidateNumbers))
|
||||||
memberPositions := make(map[int]struct{}, len(proposal.CandidateIDs))
|
memberPositions := make(map[int]struct{}, len(proposal.CandidateNumbers))
|
||||||
for _, candidateID := range proposal.CandidateIDs {
|
for _, candidateNumber := range proposal.CandidateNumbers {
|
||||||
if _, repeated := seenIDs[candidateID]; repeated {
|
if _, repeated := seenNumbers[candidateNumber]; repeated {
|
||||||
group.issues = append(group.issues, IssueRepeatedMember)
|
group.issues = append(group.issues, IssueRepeatedMember)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seenIDs[candidateID] = struct{}{}
|
seenNumbers[candidateNumber] = struct{}{}
|
||||||
position, category := resolveMember(candidateID, positionsByID)
|
position, category := resolveMember(candidateNumber, positionsByNumber)
|
||||||
if category != "" {
|
if category != "" {
|
||||||
group.issues = append(group.issues, category)
|
group.issues = append(group.issues, category)
|
||||||
continue
|
continue
|
||||||
@@ -305,7 +337,7 @@ func assessGroup(proposal DuplicateGroup, positionsByID map[int]int) assessedGro
|
|||||||
group.issues = append(group.issues, IssueFewerThanTwoMembers)
|
group.issues = append(group.issues, IssueFewerThanTwoMembers)
|
||||||
}
|
}
|
||||||
|
|
||||||
canonicalPosition, canonicalCategory := resolveCanonical(proposal.CanonicalCandidateID, positionsByID)
|
canonicalPosition, canonicalCategory := resolveCanonical(proposal.CanonicalCandidateNumber, positionsByNumber)
|
||||||
if canonicalCategory != "" {
|
if canonicalCategory != "" {
|
||||||
group.issues = append(group.issues, canonicalCategory)
|
group.issues = append(group.issues, canonicalCategory)
|
||||||
} else {
|
} else {
|
||||||
@@ -320,22 +352,22 @@ func assessGroup(proposal DuplicateGroup, positionsByID map[int]int) assessedGro
|
|||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveMember(candidateID int, positionsByID map[int]int) (int, IssueCategory) {
|
func resolveMember(candidateNumber int, positionsByNumber map[int]int) (int, IssueCategory) {
|
||||||
if candidateID <= 0 {
|
if candidateNumber <= 0 {
|
||||||
return 0, IssueMemberNonPositive
|
return 0, IssueMemberNonPositive
|
||||||
}
|
}
|
||||||
position, exists := positionsByID[candidateID]
|
position, exists := positionsByNumber[candidateNumber]
|
||||||
if !exists {
|
if !exists {
|
||||||
return 0, IssueMemberUnknown
|
return 0, IssueMemberUnknown
|
||||||
}
|
}
|
||||||
return position, ""
|
return position, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveCanonical(candidateID int, positionsByID map[int]int) (int, IssueCategory) {
|
func resolveCanonical(candidateNumber int, positionsByNumber map[int]int) (int, IssueCategory) {
|
||||||
if candidateID <= 0 {
|
if candidateNumber <= 0 {
|
||||||
return 0, IssueCanonicalNonPositive
|
return 0, IssueCanonicalNonPositive
|
||||||
}
|
}
|
||||||
position, exists := positionsByID[candidateID]
|
position, exists := positionsByNumber[candidateNumber]
|
||||||
if !exists {
|
if !exists {
|
||||||
return 0, IssueCanonicalUnknown
|
return 0, IssueCanonicalUnknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
func TestAssessProducesAStableOriginalPositionPlan(t *testing.T) {
|
func TestAssessProducesAStableOriginalPositionPlan(t *testing.T) {
|
||||||
preparation := proposalPreparation(t)
|
preparation := proposalPreparation(t)
|
||||||
response := ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
response := ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
||||||
{CandidateIDs: []int{5, 4}, CanonicalCandidateID: 5},
|
{CandidateNumbers: []int{5, 4}, CanonicalCandidateNumber: 5},
|
||||||
{CandidateIDs: []int{2, 1}, CanonicalCandidateID: 2},
|
{CandidateNumbers: []int{2, 1}, CanonicalCandidateNumber: 2},
|
||||||
}}
|
}}
|
||||||
assessment := preparation.Assess(response)
|
assessment := preparation.Assess(response)
|
||||||
want := []planGroupSnapshot{
|
want := []planGroupSnapshot{
|
||||||
@@ -28,8 +28,8 @@ func TestAssessProducesAStableOriginalPositionPlan(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reordered := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
reordered := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
||||||
{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2},
|
{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 2},
|
||||||
{CandidateIDs: []int{4, 5}, CanonicalCandidateID: 5},
|
{CandidateNumbers: []int{4, 5}, CanonicalCandidateNumber: 5},
|
||||||
}})
|
}})
|
||||||
if got := snapshotPlan(reordered.Plan()); !reflect.DeepEqual(got, want) {
|
if got := snapshotPlan(reordered.Plan()); !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("reordered plan = %#v, want %#v", 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) {
|
func TestCorrectionGuidanceCoversEveryIssueCategoryWithoutExposingInternalLabels(t *testing.T) {
|
||||||
|
numberRange := CandidateNumberRange{First: 1, Last: 8}
|
||||||
if len(issueCorrectionProse) != len(allIssueCategories) {
|
if len(issueCorrectionProse) != len(allIssueCategories) {
|
||||||
t.Fatalf("correction prose entries = %d, categories = %d", 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}
|
issues[index] = Issue{GroupIndex: index, Category: category}
|
||||||
}
|
}
|
||||||
|
|
||||||
details, err := CorrectionDetails(issues)
|
details, err := CorrectionDetails(issues, numberRange)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CorrectionDetails() error = %v", err)
|
t.Fatalf("CorrectionDetails() error = %v", err)
|
||||||
}
|
}
|
||||||
guidance, err := CorrectionGuidance(issues)
|
guidance, err := CorrectionGuidance(issues, numberRange)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CorrectionGuidance() error = %v", err)
|
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)
|
t.Fatalf("correction details = %#v guidance = %q", details, guidance)
|
||||||
}
|
}
|
||||||
for _, category := range allIssueCategories {
|
for _, category := range allIssueCategories {
|
||||||
@@ -88,20 +89,21 @@ func TestCorrectionGuidanceCoversEveryIssueCategoryWithoutExposingInternalLabels
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCorrectionDetailsDeduplicatesAndBoundsAffectedGroupLists(t *testing.T) {
|
func TestCorrectionDetailsDeduplicatesAndBoundsAffectedGroupLists(t *testing.T) {
|
||||||
|
numberRange := CandidateNumberRange{First: 1, Last: 256}
|
||||||
issues := make([]Issue, 0, 257)
|
issues := make([]Issue, 0, 257)
|
||||||
for group := 0; group < 256; group++ {
|
for group := 0; group < 256; group++ {
|
||||||
issues = append(issues, Issue{GroupIndex: group, Category: IssueMemberUnknown})
|
issues = append(issues, Issue{GroupIndex: group, Category: IssueMemberUnknown})
|
||||||
}
|
}
|
||||||
issues = append(issues, Issue{GroupIndex: 0, Category: IssueMemberUnknown})
|
issues = append(issues, Issue{GroupIndex: 0, Category: IssueMemberUnknown})
|
||||||
|
|
||||||
details, err := CorrectionDetails(issues)
|
details, err := CorrectionDetails(issues, numberRange)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CorrectionDetails() error = %v", err)
|
t.Fatalf("CorrectionDetails() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(details) != 1 || !strings.Contains(details[0], "additional affected group") || strings.Count(details[0], "Duplicate groups") != 1 {
|
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", details)
|
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 {
|
if err != nil || len(guidance) > contracts.MaxNormalizeRetryCorrectionGuidanceBytes {
|
||||||
t.Fatalf("CorrectionGuidance() = %q, %v", guidance, err)
|
t.Fatalf("CorrectionGuidance() = %q, %v", guidance, err)
|
||||||
}
|
}
|
||||||
@@ -112,10 +114,15 @@ func TestCorrectionDetailsRejectsUnknownOrInvalidIssues(t *testing.T) {
|
|||||||
{{GroupIndex: 0, Category: "future_unmapped_category"}},
|
{{GroupIndex: 0, Category: "future_unmapped_category"}},
|
||||||
{{GroupIndex: -1, Category: IssueMemberUnknown}},
|
{{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)
|
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) {
|
func TestAssessRejectsEveryUnsafeLocalGroupShape(t *testing.T) {
|
||||||
@@ -125,15 +132,15 @@ func TestAssessRejectsEveryUnsafeLocalGroupShape(t *testing.T) {
|
|||||||
group DuplicateGroup
|
group DuplicateGroup
|
||||||
category IssueCategory
|
category IssueCategory
|
||||||
}{
|
}{
|
||||||
{name: "zero member", group: DuplicateGroup{CandidateIDs: []int{0, 2}, CanonicalCandidateID: 2}, category: IssueMemberNonPositive},
|
{name: "zero member", group: DuplicateGroup{CandidateNumbers: []int{0, 2}, CanonicalCandidateNumber: 2}, category: IssueMemberNonPositive},
|
||||||
{name: "negative member", group: DuplicateGroup{CandidateIDs: []int{-1, 2}, CanonicalCandidateID: 2}, category: IssueMemberNonPositive},
|
{name: "negative member", group: DuplicateGroup{CandidateNumbers: []int{-1, 2}, CanonicalCandidateNumber: 2}, category: IssueMemberNonPositive},
|
||||||
{name: "unknown member", group: DuplicateGroup{CandidateIDs: []int{99, 2}, CanonicalCandidateID: 2}, category: IssueMemberUnknown},
|
{name: "unknown member", group: DuplicateGroup{CandidateNumbers: []int{99, 2}, CanonicalCandidateNumber: 2}, category: IssueMemberUnknown},
|
||||||
{name: "repeated member", group: DuplicateGroup{CandidateIDs: []int{1, 1}, CanonicalCandidateID: 1}, category: IssueRepeatedMember},
|
{name: "repeated member", group: DuplicateGroup{CandidateNumbers: []int{1, 1}, CanonicalCandidateNumber: 1}, category: IssueRepeatedMember},
|
||||||
{name: "too small", group: DuplicateGroup{CandidateIDs: []int{1}, CanonicalCandidateID: 1}, category: IssueFewerThanTwoMembers},
|
{name: "too small", group: DuplicateGroup{CandidateNumbers: []int{1}, CanonicalCandidateNumber: 1}, category: IssueFewerThanTwoMembers},
|
||||||
{name: "zero canonical", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 0}, category: IssueCanonicalNonPositive},
|
{name: "zero canonical", group: DuplicateGroup{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 0}, category: IssueCanonicalNonPositive},
|
||||||
{name: "negative canonical", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: -1}, category: IssueCanonicalNonPositive},
|
{name: "negative canonical", group: DuplicateGroup{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: -1}, category: IssueCanonicalNonPositive},
|
||||||
{name: "unknown canonical", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 99}, category: IssueCanonicalUnknown},
|
{name: "unknown canonical", group: DuplicateGroup{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 99}, category: IssueCanonicalUnknown},
|
||||||
{name: "canonical not member", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 3}, category: IssueCanonicalNotMember},
|
{name: "canonical not member", group: DuplicateGroup{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 3}, category: IssueCanonicalNotMember},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -151,9 +158,9 @@ func TestAssessRejectsEveryUnsafeLocalGroupShape(t *testing.T) {
|
|||||||
func TestAssessDiscardsEveryOverlappingGroupAndRetainsIndependentGroups(t *testing.T) {
|
func TestAssessDiscardsEveryOverlappingGroupAndRetainsIndependentGroups(t *testing.T) {
|
||||||
preparation := proposalPreparation(t)
|
preparation := proposalPreparation(t)
|
||||||
assessment := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
assessment := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
||||||
{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 1},
|
{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 1},
|
||||||
{CandidateIDs: []int{2, 3}, CanonicalCandidateID: 2},
|
{CandidateNumbers: []int{2, 3}, CanonicalCandidateNumber: 2},
|
||||||
{CandidateIDs: []int{4, 5}, CanonicalCandidateID: 5},
|
{CandidateNumbers: []int{4, 5}, CanonicalCandidateNumber: 5},
|
||||||
}})
|
}})
|
||||||
wantPlan := []planGroupSnapshot{{members: []int{5, 6}, canonical: 6}}
|
wantPlan := []planGroupSnapshot{{members: []int{5, 6}, canonical: 6}}
|
||||||
if got := snapshotPlan(assessment.Plan()); !reflect.DeepEqual(got, wantPlan) {
|
if got := snapshotPlan(assessment.Plan()); !reflect.DeepEqual(got, wantPlan) {
|
||||||
@@ -168,8 +175,8 @@ func TestAssessDiscardsEveryOverlappingGroupAndRetainsIndependentGroups(t *testi
|
|||||||
}
|
}
|
||||||
|
|
||||||
invalidAndSafe := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
invalidAndSafe := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
||||||
{CandidateIDs: []int{1, 99}, CanonicalCandidateID: 1},
|
{CandidateNumbers: []int{1, 99}, CanonicalCandidateNumber: 1},
|
||||||
{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2},
|
{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 2},
|
||||||
}})
|
}})
|
||||||
wantPlan = []planGroupSnapshot{{members: []int{0, 2}, canonical: 2}}
|
wantPlan = []planGroupSnapshot{{members: []int{0, 2}, canonical: 2}}
|
||||||
if got := snapshotPlan(invalidAndSafe.Plan()); !reflect.DeepEqual(got, wantPlan) {
|
if got := snapshotPlan(invalidAndSafe.Plan()); !reflect.DeepEqual(got, wantPlan) {
|
||||||
@@ -183,13 +190,13 @@ func TestAssessDiscardsEveryOverlappingGroupAndRetainsIndependentGroups(t *testi
|
|||||||
func TestAssessmentAccessorsAndInputsDoNotShareRetainedState(t *testing.T) {
|
func TestAssessmentAccessorsAndInputsDoNotShareRetainedState(t *testing.T) {
|
||||||
preparation := proposalPreparation(t)
|
preparation := proposalPreparation(t)
|
||||||
response := ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
response := ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
||||||
{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2},
|
{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 2},
|
||||||
}}
|
}}
|
||||||
assessment := preparation.Assess(response)
|
assessment := preparation.Assess(response)
|
||||||
want := snapshotPlan(assessment.Plan())
|
want := snapshotPlan(assessment.Plan())
|
||||||
|
|
||||||
response.DuplicateGroups[0].CandidateIDs[0] = 99
|
response.DuplicateGroups[0].CandidateNumbers[0] = 99
|
||||||
response.DuplicateGroups[0].CanonicalCandidateID = 99
|
response.DuplicateGroups[0].CanonicalCandidateNumber = 99
|
||||||
plan := assessment.Plan()
|
plan := assessment.Plan()
|
||||||
groups := plan.Groups()
|
groups := plan.Groups()
|
||||||
members := groups[0].MemberPositions()
|
members := groups[0].MemberPositions()
|
||||||
@@ -200,7 +207,7 @@ func TestAssessmentAccessorsAndInputsDoNotShareRetainedState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
invalid := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{
|
invalid := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{
|
||||||
CandidateIDs: []int{1, 99}, CanonicalCandidateID: 1,
|
CandidateNumbers: []int{1, 99}, CanonicalCandidateNumber: 1,
|
||||||
}}})
|
}}})
|
||||||
issues := invalid.Issues()
|
issues := invalid.Issues()
|
||||||
issues[0].GroupIndex = 99
|
issues[0].GroupIndex = 99
|
||||||
|
|||||||
@@ -44,20 +44,21 @@ func TestResponseSchemaAcceptsOnlyTheIntegerProposalShape(t *testing.T) {
|
|||||||
valid bool
|
valid bool
|
||||||
}{
|
}{
|
||||||
{name: "empty proposal", value: map[string]any{"duplicate_groups": []any{}}, valid: true},
|
{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: "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_ids": []any{1, 2}, "canonical_candidate_id": 3}}}, 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: "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: "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 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_ids": []any{1, 2}}}}, 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_ids": []any{1, 2}, "canonical_candidate_id": 1, "name": "replacement"}}}, 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: "too few members", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1}, "canonical_candidate_id": 1}}}, 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: "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: "too few members", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1}, "canonical_candidate_number": 1}}}, valid: false},
|
||||||
{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: "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: "negative member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{-1, 1}, "canonical_candidate_id": 1}}}, valid: false},
|
{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: "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: "negative member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{-1, 1}, "canonical_candidate_number": 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: "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: "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: "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 {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
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 {
|
if err := validateAgainstSchema(content, schema.JSONSchema); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -80,7 +81,7 @@ func TestResponseSchemaAcceptsOnlyTheIntegerProposalShape(t *testing.T) {
|
|||||||
if err := json.Unmarshal(content, &response); err != nil {
|
if err := json.Unmarshal(content, &response); err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
if !reflect.DeepEqual(response, want) {
|
||||||
t.Fatalf("decoded response = %#v, want %#v", 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) {
|
func retryResult(value dnd.ItemRegistry, findings, advisoryFindings []diagnostics.Finding, reconciliation semanticreconcile.Result, rejectedGroups int) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) {
|
||||||
issues := reconciliation.Issues()
|
issues := reconciliation.Issues()
|
||||||
operatorDetails := semanticreconcile.IssueDetails(issues)
|
operatorDetails := semanticreconcile.IssueDetails(issues)
|
||||||
correctionDetails, err := semanticreconcile.CorrectionDetails(issues)
|
correctionDetails, err := reconciliation.CorrectionDetails()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("build semantic correction details: %w", err)
|
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)
|
correctionDetails = append(correctionDetails, currencyGuidance)
|
||||||
additionalGuidance = append(additionalGuidance, currencyGuidance)
|
additionalGuidance = append(additionalGuidance, currencyGuidance)
|
||||||
}
|
}
|
||||||
correctionGuidance, err := semanticreconcile.CorrectionGuidance(issues, additionalGuidance...)
|
correctionGuidance, err := reconciliation.CorrectionGuidance(additionalGuidance...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("build semantic correction guidance: %w", err)
|
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: "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}}},
|
{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))
|
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
||||||
if err != nil || result.Retry != nil || len(result.Value.Items) != 2 {
|
if err != nil || result.Retry != nil || len(result.Value.Items) != 2 {
|
||||||
t.Fatalf("Normalize() = %#v, %v", result, err)
|
t.Fatalf("Normalize() = %#v, %v", result, err)
|
||||||
@@ -159,7 +159,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
|
|||||||
{Name: "Gold Piece", SourceRefs: ref(20)},
|
{Name: "Gold Piece", SourceRefs: ref(20)},
|
||||||
{Name: "Gold Pieces", SourceRefs: ref(30)},
|
{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"},
|
wantNames: []string{"Gold Piece"},
|
||||||
wantRefCounts: []int{3},
|
wantRefCounts: []int{3},
|
||||||
reasonCode: ReasonCodeDuplicateItemCollapsed,
|
reasonCode: ReasonCodeDuplicateItemCollapsed,
|
||||||
@@ -171,7 +171,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
|
|||||||
{Name: "Gold Pieces", SourceRefs: ref(10)},
|
{Name: "Gold Pieces", SourceRefs: ref(10)},
|
||||||
{Name: "Silver Pieces", SourceRefs: ref(20)},
|
{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"},
|
wantNames: []string{"Gold Pieces", "Silver Pieces"},
|
||||||
wantRefCounts: []int{1, 1},
|
wantRefCounts: []int{1, 1},
|
||||||
wantRetry: true,
|
wantRetry: true,
|
||||||
@@ -184,7 +184,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
|
|||||||
{Name: "Gold Pieces", SourceRefs: ref(10)},
|
{Name: "Gold Pieces", SourceRefs: ref(10)},
|
||||||
{Name: "Longsword", SourceRefs: ref(20)},
|
{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"},
|
wantNames: []string{"Gold Pieces", "Longsword"},
|
||||||
wantRefCounts: []int{1, 1},
|
wantRefCounts: []int{1, 1},
|
||||||
wantRetry: true,
|
wantRetry: true,
|
||||||
@@ -197,7 +197,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
|
|||||||
{Name: "Star Compass", SourceRefs: ref(10)},
|
{Name: "Star Compass", SourceRefs: ref(10)},
|
||||||
{Name: "Compass of the Stars", SourceRefs: ref(20)},
|
{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"},
|
wantNames: []string{"Compass of the Stars"},
|
||||||
wantRefCounts: []int{2},
|
wantRefCounts: []int{2},
|
||||||
reasonCode: ReasonCodeDuplicateItemCollapsed,
|
reasonCode: ReasonCodeDuplicateItemCollapsed,
|
||||||
@@ -209,7 +209,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
|
|||||||
{Name: "Gold Pieces", SourceRefs: ref(10)},
|
{Name: "Gold Pieces", SourceRefs: ref(10)},
|
||||||
{Name: "Longsword", SourceRefs: ref(20)},
|
{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"},
|
wantNames: []string{"Gold Pieces", "Longsword"},
|
||||||
wantRefCounts: []int{1, 1},
|
wantRefCounts: []int{1, 1},
|
||||||
wantRetry: true,
|
wantRetry: true,
|
||||||
@@ -253,7 +253,7 @@ func TestNormalizePreservesCandidatesForUnsafeProposalGroups(t *testing.T) {
|
|||||||
{Name: "Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
|
{Name: "Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
|
||||||
{Name: "Rope", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
|
{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))
|
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 {
|
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)
|
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: "Silver Pieces", SourceRefs: ref(30)},
|
||||||
{Name: "Rope", SourceRefs: ref(10)},
|
{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))
|
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
||||||
if err != nil || result.Retry == nil {
|
if err != nil || result.Retry == nil {
|
||||||
t.Fatalf("Normalize() = %#v, %v; want retry with independently accepted output", result, err)
|
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) {
|
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)
|
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)
|
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") {
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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"`) {
|
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) {
|
func retryResult(value dnd.LocationRegistry, findings []diagnostics.Finding, reconciliation semanticreconcile.Result) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) {
|
||||||
issues := reconciliation.Issues()
|
issues := reconciliation.Issues()
|
||||||
correctionDetails, err := semanticreconcile.CorrectionDetails(issues)
|
correctionDetails, err := reconciliation.CorrectionDetails()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("build semantic correction details: %w", err)
|
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("build semantic correction details: %w", err)
|
||||||
}
|
}
|
||||||
correctionGuidance, err := semanticreconcile.CorrectionGuidance(issues)
|
correctionGuidance, err := reconciliation.CorrectionGuidance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("build semantic correction guidance: %w", err)
|
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) {
|
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()
|
doc := semanticDocument()
|
||||||
input := dnd.LocationRegistry{Locations: []dnd.Location{
|
input := dnd.LocationRegistry{Locations: []dnd.Location{
|
||||||
{Name: "Old Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
|
{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: "Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
|
||||||
{Name: "Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
|
{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))
|
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") {
|
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)
|
t.Fatalf("Normalize() = %#v, %v; want safe retry fallback", result, err)
|
||||||
@@ -152,14 +152,14 @@ func TestReconciliationCandidatesKeepSameNameEvidenceDistinct(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var candidateInput struct {
|
var candidateInput struct {
|
||||||
Candidates []struct {
|
Candidates []struct {
|
||||||
CandidateID int `json:"candidate_id"`
|
CandidateNumber int `json:"candidate_number"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
} `json:"candidates"`
|
} `json:"candidates"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(preparation.Materials()["candidates"].Content, &candidateInput); err != nil {
|
if err := json.Unmarshal(preparation.Materials()["candidates"].Content, &candidateInput); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("prepared prompt = %#v", prepared)
|
||||||
}
|
}
|
||||||
for _, index := range []int{2, 4} {
|
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) {
|
func retryResult(value dnd.NPCRegistry, findings []diagnostics.Finding, reconciliation semanticreconcile.Result) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) {
|
||||||
issues := reconciliation.Issues()
|
issues := reconciliation.Issues()
|
||||||
correctionDetails, err := semanticreconcile.CorrectionDetails(issues)
|
correctionDetails, err := reconciliation.CorrectionDetails()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("build semantic correction details: %w", err)
|
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("build semantic correction details: %w", err)
|
||||||
}
|
}
|
||||||
correctionGuidance, err := semanticreconcile.CorrectionGuidance(issues)
|
correctionGuidance, err := reconciliation.CorrectionGuidance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("build semantic correction guidance: %w", err)
|
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{
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||||
PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "normalize-test-profile",
|
PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "normalize-test-profile",
|
||||||
Inputs: map[string]promptkit.ArtifactRef{
|
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":[]}]}`),
|
"transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -49,10 +49,10 @@ func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) {
|
|||||||
if prepared.Messages[0].Role != "system" {
|
if prepared.Messages[0].Role != "system" {
|
||||||
t.Fatalf("initial message role = %q, want system", prepared.Messages[0].Role)
|
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)
|
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)
|
t.Fatalf("NPC policy message = %q, want domain distinctions without copied ranges", prepared.Messages[2].Content)
|
||||||
}
|
}
|
||||||
for _, index := range []int{2, 4} {
|
for _, index := range []int{2, 4} {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func TestNormalizeSkipsSemanticCompletionWithoutTwoEligibleCandidates(t *testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeAppliesSafeProposalAndUsesPrivateInputs(t *testing.T) {
|
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)
|
normalizer := newNormalizer(t, client)
|
||||||
doc := semanticDocument()
|
doc := semanticDocument()
|
||||||
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
|
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
|
||||||
@@ -88,19 +88,19 @@ func TestNormalizeAppliesSafeProposalAndUsesPrivateInputs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var visible struct {
|
var visible struct {
|
||||||
Candidates []struct {
|
Candidates []struct {
|
||||||
CandidateID int `json:"candidate_id"`
|
CandidateNumber int `json:"candidate_number"`
|
||||||
} `json:"candidates"`
|
} `json:"candidates"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(completion.Inputs["candidates"].Content, &visible); err != nil {
|
if err := json.Unmarshal(completion.Inputs["candidates"].Content, &visible); err != nil {
|
||||||
t.Fatalf("decode candidates: %v", err)
|
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}) {
|
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 IDs = %v, want contiguous request-local handles", got)
|
t.Fatalf("candidate numbers = %v, want contiguous request-local handles", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeUnsafeProposalReturnsSafeRetryFallback(t *testing.T) {
|
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)
|
normalizer := newNormalizer(t, client)
|
||||||
doc := semanticDocument()
|
doc := semanticDocument()
|
||||||
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
|
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") {
|
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)
|
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)
|
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" {
|
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)
|
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)
|
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) {
|
func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) {
|
||||||
client := &recordingNPCNormalizerClient{responses: []string{
|
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_numbers":[1,2],"canonical_candidate_number":2},{"candidate_numbers":[3,99],"canonical_candidate_number":3}]}`,
|
||||||
`{"duplicate_groups":[{"candidate_ids":[1,3],"canonical_candidate_id":3}]}`,
|
`{"duplicate_groups":[{"candidate_numbers":[1,3],"canonical_candidate_number":3}]}`,
|
||||||
}}
|
}}
|
||||||
normalizer := newNormalizer(t, client)
|
normalizer := newNormalizer(t, client)
|
||||||
doc := semanticDocument()
|
doc := semanticDocument()
|
||||||
@@ -208,7 +208,7 @@ func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeRetainsRecordsWhenAProposalUsesAnUnknownHandle(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)
|
normalizer := newNormalizer(t, client)
|
||||||
doc := semanticDocument()
|
doc := semanticDocument()
|
||||||
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
|
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
|
||||||
@@ -241,14 +241,14 @@ func TestReconciliationCandidatesKeepEqualContextualDescriptorsDistinct(t *testi
|
|||||||
}
|
}
|
||||||
var candidateInput struct {
|
var candidateInput struct {
|
||||||
Candidates []struct {
|
Candidates []struct {
|
||||||
CandidateID int `json:"candidate_id"`
|
CandidateNumber int `json:"candidate_number"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
} `json:"candidates"`
|
} `json:"candidates"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(preparation.Materials()["candidates"].Content, &candidateInput); err != nil {
|
if err := json.Unmarshal(preparation.Materials()["candidates"].Content, &candidateInput); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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}}}}}
|
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:
|
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 {
|
if err := json.Unmarshal(content, out); err != nil {
|
||||||
return contracts.StructuredCompletionResponse{}, err
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,8 +181,8 @@ func TestProductionNPCNormalizationRetryUsesFinalSafeProposal(t *testing.T) {
|
|||||||
{Name: "Mira Thorn", SourceRefs: []npcProductionSourceRef{{StartUnitID: 2, EndUnitID: 2}}},
|
{Name: "Mira Thorn", SourceRefs: []npcProductionSourceRef{{StartUnitID: 2, EndUnitID: 2}}},
|
||||||
{Name: "Hooded Guard", SourceRefs: []npcProductionSourceRef{{StartUnitID: 3, EndUnitID: 3}}},
|
{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}]}`)
|
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_ids":[1,2],"canonical_candidate_id":2}]}`)
|
safe := []byte(`{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`)
|
||||||
|
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
Reference in New Issue
Block a user