Complete the semantic reconciliation roadmap

This commit is contained in:
2026-08-09 18:30:32 +00:00
parent e95e2f2220
commit 67338798aa
14 changed files with 187 additions and 1283 deletions

View File

@@ -1,9 +1,9 @@
Use the supplied positive integer candidate IDs and their cited transcript Determine whether candidates identify the same item type or unique designation
windows only to determine whether candidates identify the same item type or using their contextual labels and cited transcript windows. Do not treat nearby
unique designation. Do not treat nearby evidence, similar objects, or a shared evidence, similar objects, or a shared owner as sufficient.
owner as sufficient. Keep currency denominations, materially different item
types, and uncertain aliases separate. Do not infer an item property or Keep currency denominations and materially different item types separate. Keep
uniqueness. uncertain aliases separate. Do not infer an item property or uniqueness.
When selecting a canonical display name, choose one supplied candidate name When selecting a canonical display name, choose one supplied candidate name
that is the clearest established designation. that is the clearest established designation.

View File

@@ -1,7 +1,8 @@
Use the supplied positive integer candidate IDs and their cited transcript Determine whether candidates identify the same physical place using their
windows to determine whether candidates identify the same physical place. Do contextual labels and cited transcript windows. Do not treat matching names,
not treat matching names, nearby evidence, nested places, or generic labels as nearby evidence, nested places, or generic labels as sufficient.
sufficient. Keep parent and child places, similarly named places, and uncertain
aliases separate. Keep parent and child places separate, as well as similarly named places and
uncertain aliases.
When selecting a canonical display name, prefer the clearest established name. When selecting a canonical display name, prefer the clearest established name.

View File

@@ -1,7 +1,7 @@
Use the supplied positive integer candidate IDs and their cited transcript Determine whether candidates refer to the same individual using their
windows to determine whether candidates refer to the same individual. Preserve contextual labels and cited transcript windows. Preserve distinct individuals
distinct individuals even when their names are similar or their contextual even when their names are similar or their contextual descriptions are
descriptions are identical. identical.
When selecting a canonical display name, prefer a complete, stable proper name When selecting a 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

View File

@@ -1,809 +0,0 @@
# Semantic Reconciliation Implementation Plan
## Purpose
This document is the ordered implementation plan for the target state defined
in the [Semantic Reconciliation Roadmap](semantic-reconciliation.md). Each
numbered stage is sized for one gpt-5.6-terra implementation prompt and must be
completed in order.
The feature roadmap owns product intent and durable policy. This document owns
implementation sequence, package placement, private contracts, migration
mechanics, and verification. When a detail below conflicts with a current
implementation detail, preserve the feature-roadmap policy and deliberately
replace the superseded implementation.
## Execution Rules
For every stage:
1. Read `docs/development.md`, all files under `docs/policy/`, the feature
roadmap, the files named by the stage, and focused tests before editing.
2. Implement only the assigned stage and prerequisites left incomplete by an
earlier stage. Do not begin a later migration opportunistically.
3. Preserve unrelated user changes and public module keys. Do not weaken typed
artifact registration or introduce untyped JSON mutation.
4. Keep all default tests deterministic, offline, and credential-free. Use a
small fake structured-LLM client only at the external completion boundary.
5. Test package-level behavior and consequential invariants. Do not add prompt
length, exact asset hash, private constant, or collaborator-choreography
change detectors.
6. Run `gofmt` on changed Go files, the focused commands listed for the stage,
and `git diff --check`. Resolve failures before handing off the stage.
7. Do not update current-behavior documentation before Stage 11. ADR-0013 is
the documentation-policy exception and may record the accepted decision
before the complete implementation exists.
## Fixed Implementation Decisions
The following decisions are not left to individual stages:
- The shared package is `internal/framework/semanticreconcile`. It is
domain-neutral framework support, not a configured pipeline module and not a
D&D package.
- The initial core supports source-backed entity candidates only. It accepts
generic source references and source documents but no D&D types.
- The model-facing candidate object uses `candidate_id`, `label`, and
source-free `source_refs`. Eligible visible candidates receive contiguous
integer IDs beginning with `1`.
- The private response uses `duplicate_groups`, `candidate_ids`, and
`canonical_candidate_id`. It contains no names, labels, evidence ranges,
durable IDs, or replacement records.
- The generic response contract is version `v1`, with schema key
`semantic_reconciliation_llm`, schema ID
`notarius.generic.semantic_reconciliation.llm`, schema name
`notarius_semantic_reconciliation_llm_v1`, and registered filename
`semantic_reconciliation_llm.v1.json`.
- The complete generic default prompt ID is
`generic.semantic_reconciliation`, version `v1`. It has no default LLM
profile; callers must pass the resolved normalization profile.
- Core prompt protocol and schema are mandatory. A domain prompt may replace
only the generic semantic-policy message and must continue to select the
core-owned protocol and input presentation assets.
- Default source-context limits are radius `2`, at most `128` eligible
candidates, and at most `262144` total serialized bytes across candidate and
transcript input materials. Define these through one core-owned default
limits value. Test custom limits relationally rather than duplicating the
production literals throughout tests.
- Fewer than two eligible candidates skips the LLM without warning. Exceeding
a deterministic limit skips the LLM, preserves the deterministic
preprocessed artifact, and emits a bounded domain fallback warning without a
futile retry.
- Invalid structured output requests the normalizer's existing retry behavior.
A structurally valid proposal may retain independent safe groups while
discarding invalid groups; any discarded group requests a retry, and retry
exhaustion uses the existing deterministic fallback-warning contract.
- Transport, provider, cancellation, and context-material construction errors
remain execution errors. They are not converted into empty semantic results.
- The shared application helper never emits domain warnings or derives durable
IDs. It owns partition application, stable ordering, preservation of
ungrouped records, and provenance bookkeeping; typed domain policy owns value
consolidation, group guards, IDs, and warning presentation.
- Production registration of the generic prompt and schema belongs to the
generic registrar, which already runs before the D&D registrar. D&D prompt
definitions may mount core-owned shared assets but remain owned and hashed by
their D&D normalizer packages.
- Existing D&D prompt IDs and prompt versions remain unchanged. The new generic
schema has its own `v1` identity, so no private selector-schema compatibility
layer is required.
- Bump normalization policies to `dnd.npc_registry.normalize.v5`,
`dnd.item_registry.normalize.v3`, and
`dnd.location_registry.normalize.v3` during their respective migrations.
- No public configuration, durable D&D schema, integration contract, module
key, or validator-chain change is part of this work.
## Stage 1: Record The Semantic Reconciliation Decision
### Goal
Record the accepted architectural decision before introducing the reusable
mechanism, without describing unimplemented behavior in current architecture
or internal documentation.
### Work
- Create
`docs/adr/0013-use-request-local-candidate-handles-for-semantic-reconciliation.md`
using the repository's Nygard ADR format and `Status: Accepted`.
- Cite ADR-0003, ADR-0004, ADR-0009, and ADR-0012 where their typed-artifact,
package-boundary, evidence, and opaque-ID decisions apply.
- Record these decisions:
- semantic reconciliation is a framework mechanism used by typed normalize
stage modules;
- a model receives contextual candidate data but returns request-local
one-based integer handles only;
- the LLM proposes duplicate groups and a supplied canonical member;
- deterministic code validates and applies the proposal;
- a mandatory shared protocol is combined with an explicit generic or
domain-owned semantic policy; and
- durable IDs, copied contextual selectors, synthesized replacements,
reflection-based arbitrary JSON, and hidden cross-stage behavior are
rejected alternatives.
- State explicitly that acceptance does not imply implementation completion
and link to the feature and implementation roadmaps for status.
- Do not modify the accepted decision text of ADR-0012.
### Acceptance Criteria
- The ADR contains context, decision, alternatives, and consequences and does
not claim that the new core already exists.
- No current-behavior document outside `docs/adr/` or `docs/roadmap/` changes.
- All ADR and roadmap links resolve.
### Validation
```sh
git diff --check
```
## Stage 2: Build Bounded Candidate And Source-Context Preparation
### Goal
Create the domain-neutral candidate preparation boundary with contiguous
request-local IDs and bounded prompt materials, without changing any D&D
normalizer yet.
### Work
- Create `internal/framework/semanticreconcile` with package documentation and
source-context preparation code adapted from
`internal/modules/dnd/shared/entityreconcile/context.go`. Leave the old D&D
package in place until all migrations finish.
- Define a domain-neutral `Candidate` containing a contextual `Label` and owned
generic `[]source.SourceRef`. Do not add a durable ID field.
- Define `Limits` and one `DefaultLimits()` value with the fixed radius,
candidate, and serialized-material bounds above. Validation rejects negative
radius and non-positive maximums before doing work.
- Prepare candidates in caller order:
- validate every source reference against `source.DocumentIndex`;
- exclude a candidate with no references or any invalid reference;
- canonicalize its source-free ranges in source order and remove exact
duplicate ranges;
- assign IDs only to eligible candidates and make them contiguous from `1`;
and
- retain an internal ID-to-original-candidate-position mapping.
- Do not exclude two candidates merely because their labels and ranges are
identical. Their local integer IDs now disambiguate them.
- Serialize candidate input as an object containing `candidates`, where each
entry has `candidate_id`, `label`, and `source_refs` with only
`start_unit_id` and `end_unit_id`.
- Adapt the existing coalesced transcript-window algorithm. Preserve source
order, cloned unit metadata, and the `cited` marker; do not mutate the source
document or candidate references.
- Return an explicit preparation disposition for:
- ready materials;
- fewer than two eligible candidates; and
- candidate or combined-material limit exceeded.
- Enforce the candidate limit before rendering large materials and the byte
limit over the sum of serialized candidate and transcript material. Do not
split the request.
- Construct `contracts.LLMInputMaterial` values with stable content type,
content digest, and the existing `candidates` and `transcript` input names.
- Expose owned accessors for visible candidate mappings and materials. Do not
expose mutable retained slices or maps.
### Tests
- Port and revise the valuable behavior tests from `entityreconcile`, replacing
selector-collision expectations with contiguous-ID behavior.
- Cover invalid and missing references, non-sequential source unit IDs,
coalesced windows, candidate filtering, identical descriptors, nil source,
input ownership, and deterministic serialization.
- Use custom small limits to prove exactly-at-limit acceptance and one-over
skipping for candidate count and material bytes. Do not assert the production
default literals merely as change detectors.
- Prove model material contains no source document ID or application entity ID.
### Acceptance Criteria
- The package imports no D&D or production-module package.
- Same-label and same-range candidates remain independently selectable.
- No LLM client or response schema is introduced in this stage.
- Existing D&D behavior remains unchanged and all prior tests still pass.
### Validation
```sh
go test ./internal/framework/semanticreconcile
go test ./internal/modules/dnd/shared/entityreconcile
git diff --check
```
## Stage 3: Implement The Integer Proposal Contract And Assessor
### Goal
Add the private integer response schema and a pure deterministic assessor that
turns model proposals into immutable, safely ordered reconciliation plans.
### Work
- Add core response types corresponding exactly to:
- `duplicate_groups`;
- each group's `candidate_ids`; and
- `canonical_candidate_id`.
- Add
`assets/generic/normalize/deduplication/schemas/semantic_reconciliation_llm.v1.json`
with Draft 2020-12 metadata and the fixed schema identity above.
- Require all fields, reject unknown fields, require at least two group members,
require positive integers, and use `uniqueItems: true` for member IDs. Keep
application semantic validation authoritative.
- Add schema loading metadata and helpers to the core package, following the
existing `internal/framework/llm` response-schema pattern.
- Implement assessment against the prepared request mapping:
- reject zero, negative, or unknown member and canonical IDs;
- reject repeated members even if schema validation was bypassed in a unit
test;
- reject groups with fewer than two distinct valid members;
- reject a canonical ID that is not a valid member;
- mark every otherwise-local-valid group sharing a member with another such
group as conflicting and discard all conflicting groups;
- retain independent safe groups even when another group is discarded; and
- report stable issue categories and original response group indexes for
retry diagnostics.
- Produce an immutable plan expressed in original candidate positions rather
than model-visible IDs. Sort members by original position and safe groups by
earliest member so response ordering cannot alter deterministic output.
- Provide owned accessors for plan groups, issues, discarded-group count, and
whether retry is required.
### Tests
- Cover empty proposals, valid multi-member groups, unknown IDs, repeated IDs,
canonical-not-member, too-small groups, overlapping groups, independent safe
plus invalid groups, and response-order independence.
- Compile and validate representative accepted and rejected JSON against the
actual schema offline.
- Prove returned plans and issues cannot mutate retained assessment state.
- Add a focused fuzz test only if it remains small and protects the partition
and no-panic invariants more efficiently than table cases.
### Acceptance Criteria
- The model response types contain no contextual selectors or source ranges.
- Invalid groups cannot enter the plan, and overlapping groups cannot be
partially accepted.
- Plan ordering depends only on deterministic candidate order.
- The old D&D response schema remains temporarily available for unmigrated
normalizers.
### Validation
```sh
go test ./internal/framework/semanticreconcile
git diff --check
```
## Stage 4: Add Generic Prompt Assets And Production Asset Registration
### Goal
Provide one complete generic default prompt, reusable mandatory protocol/input
assets for domain prompts, and one production registration owner for the new
prompt and schema.
### Work
- Replace the two draft files currently under
`assets/generic/normalize/deduplication/` with a coherent asset set:
- `prompts/prompt.yaml` for `generic.semantic_reconciliation` `v1`;
- `prompts/system.md` with provider-neutral structured reconciliation role;
- `prompts/protocol.md` with the mandatory integer selection and safety
contract;
- `prompts/instructions.md` with the conservative generic semantic default;
- `prompts/candidates.md`; and
- `prompts/transcript-windows.md`.
- The default prompt declares required `candidates` and `transcript`
`application/json` inputs, omits `default_profile`, selects the new schema,
uses zero PromptKit repair attempts, and orders messages from stable to
variable:
1. generic system;
2. mandatory protocol;
3. generic semantic instructions, carrying the ephemeral cache marker for
the stable prefix;
4. candidate material; and
5. transcript windows.
- Keep protocol, semantic policy, and presentation as separate assets even
though the rendered prompt is compact. Domain prompts must be able to reuse
protocol and presentation while substituting only semantic instructions.
- In `internal/framework/semanticreconcile`, add scoped asset loading,
deterministic prompt/schema hashes, `RegisterAssets`, and a narrow allowlist
helper that returns core-owned shared prompt files for a consuming manifest.
The root `assets` package remains unchanged and contains no logic.
- Extend `internal/modules/dnd/shared.PromptAssetManifest` to accept explicitly
supplied external shared files in addition to named D&D shared files. Include
those external files in both the mounted prompt filesystem and prompt hash.
Reuse `promptfs.SharedPromptFile`; do not teach D&D shared code paths about a
hard-coded generic directory.
- Update `internal/modules/generic/register.Register` to require a non-nil asset
registry and register the semantic-reconciliation assets before its existing
modules and validators. Preserve contextual duplicate-registration errors.
- Do not yet change the three D&D normalization prompts or remove their old
schema registration.
### Tests
- Test the complete generic prompt and schema can be registered and prepared
offline with an explicit test profile and representative integer candidate
material.
- Verify the prepared output contract selects the generic schema and that the
rendered prompt asks for integer IDs rather than names or source ranges in
the response. Do not snapshot all prose or its exact length.
- Extend D&D manifest tests for external shared-file mounting, hashing, missing
files, and duplicate destinations at the stable manifest boundary.
- Update generic registrar tests for nil assets, successful asset registration,
and duplicate registration without duplicating PromptKit internals.
### Acceptance Criteria
- Generic production registration makes the complete default prompt and schema
available before D&D registration.
- Domain prompt manifests can mount the exact core protocol bytes without
copying them.
- The generic prompt has a stable prefix followed only by variable candidates
and transcript material.
- Existing D&D prompt behavior remains operational during the migration.
### Validation
```sh
go test ./internal/framework/semanticreconcile ./internal/framework/promptfs
go test ./internal/modules/generic/register ./internal/modules/dnd/shared
go test ./internal/cli -run 'Production|Catalog|Prompt'
git diff --check
```
## Stage 5: Add The Shared Structured-LLM Reconciliation Engine
### Goal
Centralize prompt execution and outcome classification behind one
provider-neutral engine while leaving typed artifact mutation to later stages.
### Work
- Add an `Engine` in `internal/framework/semanticreconcile` constructed with a
non-nil `contracts.StructuredLLMClient`, validated limits, and a prompt spec.
A prompt spec contains a non-empty prompt ID, version, and SHA-256 digest; it
may identify the generic default or a domain-owned prompt that obeys the same
schema. Validate the digest at construction so manifest metadata and
checkpoint fingerprints never silently omit the selected prompt bytes.
- Define a request containing stage name, source document, candidates, resolved
profile ID, and session ID. Preserve profile and session values exactly in
`contracts.StructuredCompletionRequest`.
- The engine must:
- reject nil engine, client, context, and invalid construction state with
contextual errors;
- honor context cancellation before material preparation and completion;
- use Stage 2 preparation and skip without calling the LLM for insufficient
or over-limit inputs;
- call `CompleteStructured` exactly once for a ready request, using only the
`candidates` and `transcript` materials;
- classify `contracts.ErrInvalidStructuredOutput` as a retryable semantic
outcome rather than a transport error;
- return other completion failures as errors with stage context;
- assess a decoded response through Stage 3; and
- return a safe plan, stable issues, discarded count, and a disposition that
distinguishes complete, retryable invalid structured output, retryable
discarded proposal groups, insufficient candidates, and deterministic
limit skip.
- Keep normalize-stage reason codes, warning text, and `NormalizeRetry`
construction out of the engine. The engine exposes neutral classifications
that typed normalizers translate through their existing policies.
- Expose core manifest metadata and checkpoint fingerprints for prompt ID,
version and hash, schema identity and hash, core reconciliation policy, and
complete limits. Domain normalizers append their identity and normalization
policy fingerprints rather than rebuilding core metadata independently.
- Fingerprint the limit policy as one canonical core-owned value so any limit
change invalidates relevant normalization checkpoints without test suites
duplicating every literal.
### Tests
- Use a compact recording fake client to cover request propagation, successful
assessment, empty groups, invalid structured output, transport failure,
cancellation, insufficient candidates, candidate limit, and material limit.
- Prove no LLM call occurs for deterministic skip outcomes.
- Prove repeated calls do not retain plans, issues, candidate mappings, or
warnings from earlier calls.
- Test metadata and fingerprints relationally: required categories must be
present and change when supplied prompt/schema/limit policy changes; do not
freeze current digest values.
### Acceptance Criteria
- Consuming modules no longer need to construct the common structured
completion request once migrated.
- The engine has no dependency on pipeline retry orchestration or a D&D type.
- Provider and transport behavior remain behind `StructuredLLMClient`.
- The old normalizers remain unchanged and compiling.
### Validation
```sh
go test ./internal/framework/semanticreconcile
go test ./internal/framework/llm ./internal/framework/contracts
git diff --check
```
## Stage 6: Add Generic Typed Plan Application
### Goal
Centralize safe partition application, ordering, and provenance bookkeeping for
typed records without moving domain consolidation rules into the core.
### Work
- Add a generic record envelope that owns a typed value, sorted unique original
input indexes, and earliest deterministic input position. Constructors and
accessors must preserve caller ownership.
- Add a deliberately small typed application policy with these responsibilities:
- clone one typed value;
- optionally reject an otherwise safe semantic group through a stable neutral
category; and
- consolidate owned member values using one supplied canonical member.
- Implement plan application that:
- validates plan positions against the provided record slice;
- never mutates records or policy inputs;
- preserves every ungrouped record as an owned clone;
- emits exactly one record for an accepted group;
- preserves all members unchanged when the typed guard rejects a group;
- unions and sorts original input indexes in core bookkeeping;
- sets the resulting earliest position to the earliest member;
- places output by earliest contributing position; and
- returns neutral applied-group and rejected-group events containing the
provenance needed for domain warning and retry construction.
- Treat a policy consolidation error as an execution error unless it is the
explicit safe-group rejection result. Do not silently preserve records after
an unexpected application failure.
- Do not make the helper aware of source references, names, durable IDs,
currencies, people, or locations. Domain consolidators continue to union and
canonicalize evidence inside their typed value and derive the final ID.
### Tests
- Use a minimal synthetic typed record to cover no groups, one and multiple
groups, ungrouped preservation, canonical member selection, output ordering,
provenance union, guard rejection, malformed plan positions, consolidation
failure, nil versus empty ownership, and non-mutation.
- Prefer table-driven invariant tests over tests for private loops or callback
invocation order.
### Acceptance Criteria
- A future domain adapter can apply a plan without reimplementing partition
traversal or provenance ordering.
- Rejected groups preserve all members exactly once.
- The helper cannot derive a domain ID or emit a domain warning.
### Validation
```sh
go test ./internal/framework/semanticreconcile
git diff --check
```
## Stage 7: Migrate NPC Registry Normalization
### Goal
Use the shared engine and typed application path for NPC reconciliation while
preserving the existing NPC artifact and failure semantics.
### Work
- Update the NPC normalization prompt manifest and assets:
- keep prompt ID `dnd.npc_registry.normalize` and version `v1`;
- retain the D&D system prompt and NPC-owned semantic instructions;
- mount the generic protocol, candidate presentation, and transcript-window
assets from `semanticreconcile`;
- order messages as D&D system, generic protocol, NPC semantic instructions
with the stable-prefix cache marker, candidates, then transcript windows;
and
- select `semantic_reconciliation_llm.v1.json`.
- Remove requirements for the model to copy names or ranges. Adjust NPC policy
prose to describe integer candidate selection while retaining all current
individual-person and canonical-name distinctions.
- Construct a shared engine in `npcregistry.New`, using the NPC prompt spec and
default limits. Replace direct `BuildContext`, `CompleteStructured`, and
`Assessment` orchestration with one engine call.
- Convert deterministic preprocessed NPC records into core typed envelopes and
candidates. Keep exact deterministic name/evidence normalization before the
semantic pass.
- Implement the narrow NPC typed policy:
- no additional group guard;
- choose the canonical member's normalized NPC name;
- union and source-order all member references;
- derive the final NPC ID through `npcs/identity`; and
- return an owned value.
- Translate engine and application outcomes to existing NPC warnings and retry
behavior. Safe independent groups may appear in the retry candidate result;
invalid structured output retries from the deterministic value; limit skip
returns the deterministic value with one bounded reconciliation-exhausted
warning and no retry.
- Preserve existing NPC reason-code strings and warning cap. Bump only the
normalization policy to `v5`.
- Replace locally assembled common prompt/schema/context metadata and
fingerprints with the core-provided values plus NPC identity and
normalization policy. Retain user-useful manifest field names where practical
but ensure the new generic schema identity and complete limits are recorded.
- Delete NPC-only key translation, safe-group traversal, and duplicated generic
application helpers made obsolete by the core. Retain NPC-specific
preprocessing, consolidation policy, and diagnostics.
### Tests
- Rewrite fake LLM responses to return integer IDs and inspect candidate input
only where needed to prove contiguous IDs and no durable identity leakage.
- Preserve behavior coverage for exact deterministic consolidation, semantic
aliases, canonical name choice, evidence union, ID recomputation, warnings,
invalid proposal retry, exhaustion fallback, cancellation, ownership, and
no-call cases.
- Add focused coverage for identical contextual NPC descriptors remaining
independently addressable and for a limit skip producing no LLM call.
- Update prompt preparation tests for the generic schema and shared protocol
without snapshotting full prompt prose or exact message length.
### Acceptance Criteria
- NPC normalization imports `semanticreconcile` and no longer imports the old
D&D `entityreconcile` package.
- Its model response requires only integer candidate handles.
- Durable NPC output and module key remain unchanged.
- Focused tests demonstrate behavior parity plus the safer selection protocol.
### Validation
```sh
go test ./internal/modules/dnd/normalize/npcregistry
go test ./internal/framework/semanticreconcile
go test ./internal/modules/dnd/register -run 'NPC|Prompt|Schema'
git diff --check
```
## Stage 8: Migrate Item Registry Normalization
### Goal
Move item registry reconciliation onto the shared path while preserving its
item-type and currency safety rules.
### Work
- Update the item normalization prompt exactly as in Stage 7, retaining prompt
ID `dnd.item_registry.normalize`, version `v1`, and item-owned semantic policy
while selecting the generic protocol, presentation assets, and schema.
- Construct and use the shared engine with default limits. Convert deterministic
item records into core candidates and typed envelopes.
- Implement the item typed policy:
- retain the existing currency-denomination group guard;
- reject a group mixing currency with non-currency or different
denominations through the core's explicit typed-group rejection path;
- choose the canonical member's normalized item name;
- union and source-order evidence;
- derive the item ID through `items/identity`; and
- preserve all current item-type and unique-designation semantics.
- Count a typed currency guard rejection with discarded model groups for retry
and exhaustion diagnostics. Preserve rejected group members unchanged and
exactly once.
- Translate insufficient, limit, invalid structured, invalid proposal, valid,
and transport outcomes consistently with NPC behavior while retaining item
reason codes and warning cap.
- Bump the item normalization policy to `v3`, adopt core metadata and
fingerprints, and remove item copies of generic key translation, group
traversal, and application logic.
### Tests
- Convert semantic response fixtures to integer IDs.
- Retain coverage for item aliases, canonical names, evidence, IDs, warnings,
retries, fallback, ownership, and no-call cases.
- Specifically protect same-denomination alias acceptance, cross-denomination
rejection, currency/non-currency rejection, preservation of every rejected
member, and combination of an accepted independent group with a rejected
group.
- Update prompt/schema preparation tests without exact prompt snapshots.
### Acceptance Criteria
- Item normalization no longer imports old `entityreconcile` code.
- Its domain guard is expressed only through the typed policy and cannot be
bypassed by a schema-valid proposal.
- Durable item output and public module behavior remain unchanged.
### Validation
```sh
go test ./internal/modules/dnd/normalize/itemregistry
go test ./internal/framework/semanticreconcile
go test ./internal/modules/dnd/register -run 'Item|Prompt|Schema'
git diff --check
```
## Stage 9: Migrate Location Registry Normalization
### Goal
Complete adoption by moving location registry reconciliation onto the shared
engine and typed application path.
### Work
- Update the location normalization prompt as in Stages 7 and 8, retaining
prompt ID `dnd.location_registry.normalize`, version `v1`, and the
location-owned physical-place semantic policy.
- Construct and use the shared engine with default limits. Convert deterministic
location records into core candidates and typed envelopes.
- Implement the location typed policy:
- no additional group guard beyond existing location semantic policy;
- choose the supplied canonical member's normalized location name;
- union and source-order all member references;
- derive the location ID from the final name and final references through
`locations/identity`; and
- preserve same-name, parent/child, and physical-place distinctions.
- Translate outcomes through existing location retry, fallback, reason-code,
and warning-cap behavior. Bump the normalization policy to `v3`.
- Adopt core metadata and fingerprints and delete location copies of generic
reconciliation and application mechanics.
### Tests
- Convert semantic response fixtures to integer IDs while retaining physical
place, same-name, canonical-name, evidence-dependent ID, warning, retry,
fallback, ownership, and no-call coverage.
- Prove same-name records with distinct evidence can both be addressed by IDs
without copied selectors.
- Update prompt/schema preparation tests without freezing prompt prose.
### Acceptance Criteria
- All three semantic D&D registry normalizers use the shared core.
- Location durable IDs are still derived only after final evidence union.
- No location code imports old `entityreconcile`.
### Validation
```sh
go test ./internal/modules/dnd/normalize/locationregistry
go test ./internal/framework/semanticreconcile
go test ./internal/modules/dnd/register -run 'Location|Prompt|Schema'
git diff --check
```
## Stage 10: Remove The Legacy Reconciliation Path And Consolidate Registration
### Goal
Delete superseded selector-copying code and assets, leave one schema and core,
and verify production composition as a whole.
### Work
- Delete `internal/modules/dnd/shared/entityreconcile` after confirming no
imports remain.
- Delete `assets/dnd/entity-reconciliation/` and
`assets/dnd/shared/prompts/common-dnd-entity-reconciliation.md`.
- Remove the obsolete shared prompt-name entry and the D&D registrar's old
entity-reconciliation schema registration.
- Remove any obsolete local candidate or transcript template files from the
three normalization asset trees after their manifests use the shared generic
presentation assets. Keep only their `prompt.yaml` and domain semantic
`instructions.md` unless another file remains genuinely domain-specific.
- Ensure the generic registrar is the sole production owner of generic
reconciliation prompt/schema registration and that the existing production
registrar order remains generic, Seriatim, D&D.
- Update registrar and CLI composition tests so they prove assembled production
assets are complete. A D&D registrar unit test may pre-register required
generic assets or limit itself to D&D-owned assets; do not duplicate generic
asset ownership inside D&D merely to preserve an old isolated-test setup.
- Search all Go, asset, and roadmap-adjacent current documentation for old
schema keys, selector response types, `common-dnd-entity-reconciliation.md`,
and model-output source ranges. Remove only obsolete uses; source ranges
remain valid model input.
- Review the three normalizers side by side. Consolidate any remaining
demonstrated neutral retry/adaptation helper into the core if it can be done
without domain warning semantics; otherwise keep the difference explicit.
### Tests
- Run the complete framework, generic module, D&D module, D&D registrar, and
production composition suites.
- Add or retain one assembled prompt/schema test per meaningful owner rather
than reproducing every core schema case in all three domains.
- Confirm duplicate production registration still fails contextually and no
prompt or schema path collides.
### Acceptance Criteria
- Exactly one semantic reconciliation response schema and assessor exist.
- No model-facing reconciliation response requires a name or evidence range.
- Production composition registers every selected D&D normalization prompt and
the generic schema exactly once.
- No compatibility shim or dead legacy asset remains.
### Validation
```sh
go test ./internal/framework/...
go test ./internal/modules/generic/...
go test ./internal/modules/dnd/...
go test ./internal/cli -run 'Production|Catalog|Prompt|Schema'
rg -n 'dnd_entity_reconcile_llm|common-dnd-entity-reconciliation|entityreconcile' internal assets docs/internal docs/policy docs/config.md
git diff --check
```
The `rg` command should return no live legacy references. Separately review
roadmap and ADR historical references rather than deleting accurate decision
history.
## Stage 11: Finalize Current Documentation And Repository Verification
### Goal
Make current documentation accurately describe the implemented architecture,
then perform the complete offline verification pass.
### Work
- Update `docs/policy/architecture.md` to:
- distinguish an artifact family from one configured stage module without
changing the fixed pipeline;
- identify semantic reconciliation as a domain-neutral framework mechanism;
- state that request-local candidate handles are an approved application of
ADR-0012 and link ADR-0013; and
- retain typed domain mutation, evidence, and dependency-direction
invariants.
- Update `docs/internal/overview.md` with the implemented
`internal/framework/semanticreconcile` responsibility, linking to the focused
internal owners rather than duplicating mechanics.
- Update `docs/internal/modules.md` to document artifact-family ownership,
stage-module registration, and how a typed normalizer instantiates the shared
strategy.
- Update `docs/internal/dnd.md` to replace selector-copying descriptions with
the integer candidate protocol, generic protocol/domain policy composition,
limit behavior, typed application boundary, and the three registry-specific
rules. Keep exact public keys and validator chains in `docs/config.md` rather
than duplicating them.
- Update `docs/internal/llm.md` only as needed to identify ownership and
registration of the generic prompt/schema assets and checkpoint metadata.
- Do not change integration contracts unless repository inspection finds an
incorrect statement: the durable artifact shapes are intentionally
unchanged. Do not add user configuration documentation because this feature
adds no configuration.
- Confirm ADR-0013 accurately matches the final implementation. Do not rewrite
its accepted decision to accommodate accidental implementation drift; fix
the implementation or create a superseding decision if a genuine conflict
was discovered.
- Review `docs/roadmap/future.md` only for links and scope boundaries. Keep
batching, operator-selected policy, broader inputs, a universal module key,
and physical package reorganization deferred.
- Run formatting, tests, vet, build, whitespace, link, and stale-reference
checks. No command may contact a live LLM provider.
### Acceptance Criteria
- Current docs describe only implemented behavior and assign each fact to its
canonical owner.
- The architecture clearly separates artifact families, stage modules, shared
model judgment, and typed deterministic application.
- All durable D&D schemas, module keys, and examples remain valid.
- No stale selector-copying documentation or asset path remains.
- The repository-wide test, vet, and build checks pass offline.
### Validation
```sh
go test ./...
go vet ./...
go build ./cmd/notarius
git diff --check
```
Run `gofmt` on every changed Go file before these commands; do not pass package
directories to `gofmt`.
Also verify every changed Markdown link target manually or with the repository's
available link checker. Review `git status --short` and the complete diff to
confirm that no unrelated file or generated secret-bearing material was added.

View File

@@ -1,426 +0,0 @@
# Semantic Reconciliation Roadmap
## Purpose
This roadmap defines a reusable, LLM-assisted semantic-reconciliation facility
for source-backed entity registries. The facility will centralize the common
candidate preparation, prompt execution, proposal validation, safety, retry,
and consolidation mechanics currently implemented by the D&D NPC, item, and
location registry normalizers while preserving typed, domain-owned output.
The model will remain a constrained proposal source. Notarius will retain
authority over candidate identity, proposal validation, deterministic mutation,
provenance, durable identifiers, warnings, and final artifact construction.
## Motivation
NPC, item, and location registry normalization now demonstrate the same useful
pattern: deterministic preprocessing produces candidate records, an LLM judges
whether some candidates describe the same underlying entity, and deterministic
code applies only safe proposed groups. The current D&D-shared implementation
proves the approach, but it still duplicates orchestration and application
logic across normalizers and requires the model to reproduce complete
contextual selectors containing names and evidence ranges.
The target design should provide one efficient and thoroughly tested
reconciliation core that can support additional artifact families without
moving domain semantics into generic code. It should also simplify the model's
task by replacing selector reproduction with small request-local integer
handles.
## Goals
- Establish one domain-neutral semantic-reconciliation core for source-backed
entity candidates.
- Move all demonstrated common mechanics into that core, including structured
LLM execution and deterministic proposal assessment.
- Preserve exact typed artifact ownership from merged input through normalized
output.
- Give the model semantic evidence while asking it to return only small,
request-local candidate identifiers.
- Provide a conservative generic semantic prompt policy that a typed artifact
family may use by default.
- Allow an artifact family to supply narrower domain semantic policy without
replacing the mandatory shared protocol and safety instructions.
- Have the NPC, item, and location registry normalizers use the shared core
without changing their durable artifact contracts, configured module keys,
domain identity rules, or fallback guarantees.
- Make addition of another eligible registry normalizer primarily an adapter
and policy exercise rather than a copy of reconciliation machinery.
## Terminology And Ownership
A **stage module** remains one configured implementation of one pipeline stage.
An extractor and a normalizer are separate stage modules even when they
collaborate on the same artifact kind.
An **artifact family** is the cohesive domain feature that owns an artifact's
types, codec, extractor, merge choice, normalizer, validators, prompt policy,
schemas, identity helpers, and reference projections. For example, D&D spells
and the D&D NPC registry are artifact families whose implementations span
multiple explicit pipeline stages.
This terminology clarifies existing ownership without changing Notarius's
fixed pipeline or combining stages. It is consistent with the accepted
domain-first organization and typed generic-strategy boundary in
[ADR-0004](../adr/0004-package-modules-by-domain.md). The D&D registrar remains
responsible for composing D&D-owned stage modules and their policies; the
generic core remains unaware of D&D types or semantics.
## Target Architecture
### Shared Core
A domain-neutral framework package should own semantic reconciliation. It may
depend on generic source and structured-completion contracts and may consume
its scoped assets under `assets/generic/`, but it must not import a production
domain or encode D&D identity rules.
The shared core owns:
- validation and defensive copying of its inputs;
- candidate eligibility and stable input ordering;
- assignment of model-visible request-local candidate IDs;
- construction of bounded candidate and source-context materials;
- invocation of the configured structured LLM prompt;
- the private duplicate-group response schema;
- exact resolution of response IDs to the candidates visible in that request;
- rejection of malformed, unknown, repeated, ambiguous, or overlapping
proposals;
- canonical ordering of accepted groups and group members;
- construction of an immutable reconciliation plan;
- shared retry, fallback, cancellation, and invalid-output classification;
- generic consolidation and provenance mechanics that are demonstrably common
across typed consumers; and
- metadata and checkpoint fingerprints for shared prompts, schemas, and policy
versions.
The core should expose stable behavior rather than a collection of unrelated
helpers. Its API should make the safe path direct: a caller supplies typed
candidates and a narrow domain policy, and receives either a deterministic
typed result or a validated reconciliation plan that can only be applied
through the typed policy boundary.
### Typed Artifact-Family Adapter
Each consuming artifact family owns a typed adapter or policy that supplies the
irreducibly domain-specific behavior:
- projection of merged typed records into eligible reconciliation candidates;
- the semantic identity scope being reconciled;
- an optional domain semantic-policy prompt asset;
- canonical-field selection beyond choosing the supplied canonical member;
- domain field and evidence consolidation rules;
- durable ID derivation;
- domain warning scopes, reason codes, and messages; and
- domain postconditions and fallback behavior not covered by the shared core.
The adapter must not parse untyped durable JSON or weaken the artifact codec's
exact Go type. The core must not use reflection to infer domain fields. Shared
typed consolidation support may use Go generics and a deliberately small policy
interface where that removes demonstrated duplication without hiding domain
rules.
### Pipeline Boundary
Semantic reconciliation remains an implementation of the normalize stage. It
does not add a pipeline stage, hide normalization inside extraction, or create
an arbitrary workflow edge. Existing typed normalizer registrations remain the
public configuration boundary.
The initial implementation does not register one universal
`generic/deduplication` module key. A module key cannot safely accept arbitrary
artifact kinds under the current exact typed-registration contract. Domain
registrars instead instantiate the shared strategy for their own artifact
types.
## Request-Local Candidate Protocol
### Candidate Presentation
After deterministic preprocessing and eligibility filtering, the core assigns
the candidates visible to one completion request contiguous integer
`candidate_id` values beginning with `1`. The mapping is owned by that request
and retains the original typed candidate and input position internally.
Each model-facing candidate includes its `candidate_id`, contextual display
name or label, and the evidence and context needed for semantic judgment. In
the initial source-backed implementation, that context consists of validated
source-reference ranges and bounded, source-ordered transcript windows.
Application-owned durable entity IDs are never included.
Candidate IDs:
- are identifiers for prompt selections, not entity identities;
- have meaning only within one structured completion request;
- restart for each request or future batch;
- never enter a durable artifact or public schema;
- are not used to derive durable IDs; and
- must be resolved through the core's retained request-local mapping.
Only model-visible eligible candidates receive IDs. Filtering must not produce
gaps that increase model burden or reveal unrelated internal ordering.
### Proposal Response
The private structured response contains an ordered `duplicate_groups` array.
Each group contains:
- `candidate_ids`: at least two distinct supplied integer IDs; and
- `canonical_candidate_id`: one supplied ID that is also a member of that
group.
The model does not return candidate names, source ranges, replacement records,
durable IDs, or synthesized canonical values. Choosing a canonical candidate
means selecting one supplied member; typed deterministic code constructs the
resulting record.
The response schema should reject unknown fields and require every defined
field. Application validation remains authoritative and must additionally
reject out-of-range IDs, repeated members, a canonical ID outside its group,
and any candidate appearing in more than one group. Schema validation is not a
substitute for these semantic checks.
### Deterministic Plan
The core resolves accepted IDs to internal candidates, orders members by their
original deterministic positions, and orders groups by their earliest member.
Candidates omitted from the response remain distinct. Invalid groups are never
partially applied.
The reconciliation plan retains enough internal provenance for typed
application, warning generation, debugging, and validation without exposing
request-local IDs as durable identity.
## Prompt Policy
### Mandatory Shared Protocol
The core owns a shared prompt fragment that defines the response protocol and
non-negotiable safety behavior. It instructs the model to:
- identify only well-supported groups that denote the same underlying entity;
- preserve candidates that are merely similar or uncertain;
- return only supplied candidate IDs;
- select one supplied group member as canonical;
- omit uncertain groups; and
- invent no candidates, evidence, attributes, identities, or replacements.
This protocol fragment and the private schema are not replaceable by a domain
adapter. Keeping them shared ensures identical mechanics across consumers and
provides one prompt prefix for review, testing, and provider caching.
### Generic Semantic Default
The core provides a conservative generic semantic-policy fragment suitable for
an artifact family whose notion of entity identity is adequately conveyed by
its candidate labels and evidence. It asks whether candidates refer to the same
underlying entity and treats uncertainty as a reason not to collapse them.
Use of the generic default is explicit in the typed adapter. It is not an
implicit fallback for an adapter that failed to declare its policy.
### Domain Semantic Policy
An artifact family may select a domain-owned semantic-policy fragment in place
of the generic semantic fragment. The replacement defines only domain judgment
and canonical-member preferences; it does not replace the shared protocol,
response schema, or deterministic safety rules.
The existing NPC, item, and location policies remain domain-owned because they
encode meaningful distinctions among people, item types or unique
designations, currency denominations, parent and child places, and same-name
physical locations. Their prompt manifests should select the shared protocol
and their local semantic fragment while using the same generic response
schema.
Prompt selection is initially an implementation-time artifact-family choice.
This work does not add arbitrary operator-supplied prompt paths or configuration
that can replace reconciliation safety policy.
## Context, Bounds, And Model Invocation
The first shared core is intentionally scoped to candidates grounded in a
Notarius source document. It validates candidate source references, creates
bounded windows around their evidence, preserves source order, and supplies
the model with the relationship between each candidate ID and its evidence.
Source ranges are model input but are never model output.
The core skips the LLM call when fewer than two candidates remain eligible. It
must also impose explicit candidate-count and rendered-context bounds. When an
input cannot be reconciled safely within those bounds, normalization preserves
the deterministic preprocessed result and produces bounded diagnostics under
the consuming module's established fallback policy. It must not silently
process arbitrary fixed-size slices that could separate duplicates.
Every completion uses the injected scheduled structured-LLM client, propagated
profile and session ID, cancellation, and the normalizer retry contract.
Invalid structured output or an unusable proposal follows the existing
retry-then-deterministic-fallback model. Transport and provider failures remain
execution errors rather than being silently converted into semantic absence.
## Deterministic Typed Application
The model never mutates the artifact. The shared core and typed adapter apply
only fully validated groups.
Application must preserve these invariants:
- no ungrouped candidate is inserted, removed, or changed by semantic
reconciliation;
- every accepted group produces exactly one typed output record;
- canonical display fields come from a supplied group member unless a domain
policy explicitly performs a deterministic transformation;
- all required provenance from group members is retained and canonicalized;
- output order follows the earliest contributing deterministic input position;
- durable IDs are recomputed by the domain identity policy after
consolidation;
- caller-owned input and request material are never mutated;
- warnings identify every collapsed group using domain-owned scopes and reason
codes; and
- warning volume remains bounded.
Any shared typed application helper must make these invariants structural while
leaving domain field merging and ID derivation explicit.
## Target D&D Consumers
### NPC Registry
NPC normalization uses the shared core while preserving its individual-person
identity semantics, proper-name canonicalization policy, evidence union,
deterministic NPC ID derivation, warning behavior, retry, and fallback result.
Its model response uses integer candidate selection rather than copied
contextual selectors.
### Item Registry
Item normalization uses the shared core while preserving the distinction
between item types and unique designations, distinct currency denominations,
non-inference of item properties or uniqueness, item identity derivation,
warnings, and fallback behavior.
### Location Registry
Location normalization uses the shared core while preserving its
physical-place identity semantics, treatment of parent and child places and
same-name places, source-reference-dependent durable IDs, warnings, and
fallback behavior.
The migrations must retain the existing durable artifact schemas and public
module keys. Prompt, schema, policy, and implementation fingerprint changes
must invalidate only the affected normalization checkpoints through the normal
checkpoint identity mechanism.
The target tree contains no superseded D&D-specific selector-copy response
schema, shared reconciliation implementation, or obsolete prompt assets. It
does not retain compatibility shims for the private pre-release LLM response
contract.
## Verification Strategy
Tests should protect the reconciliation contract and realistic failure modes,
not private helper structure or exact prompt length.
The shared core warrants focused behavioral coverage for:
- contiguous ID assignment after eligibility filtering;
- absence of durable IDs from model inputs and absence of evidence ranges from
model outputs;
- exact ID resolution and rejection of zero, negative, unknown, repeated, and
overlapping IDs;
- canonical membership and minimum group size;
- deterministic group and output ordering regardless of response order;
- preservation of ungrouped candidates and complete provenance;
- ownership and defensive-copy guarantees;
- cancellation, invalid structured output, retry, and fallback behavior;
- candidate and context bounds; and
- offline prompt/schema registration and structured response decoding.
Use table-driven or property-oriented tests where they efficiently protect
group-partition and preservation invariants. Fuzzing is appropriate for the
pure proposal assessor if it remains fast and deterministic. Do not add tests
that merely freeze prompt text, message counts, asset hashes, private constant
values, or implementation call choreography.
Each migrated D&D normalizer retains focused tests for its domain identity,
consolidation, warnings, durable IDs, and fallback behavior. A small number of
integration tests should prove that production registration supplies the
shared schema and selected prompt policy. Live-model evaluation remains a
human review tool and is not part of the default offline test suite.
## Architectural Decisions And Documentation
The target documentation set includes an accepted ADR applying
[ADR-0012](../adr/0012-resolve-opaque-entity-identifiers-deterministically.md)
to semantic reconciliation. That decision records:
- request-local ordinal candidate handles as the standard reconciliation
selection mechanism;
- why requiring the model to reproduce contextual selectors is unnecessary
and error-prone;
- the mandatory shared protocol plus generic or domain semantic-policy
composition;
- LLM proposal versus deterministic application ownership;
- the typed adapter boundary; and
- rejected alternatives, including durable IDs, name-only selection,
model-synthesized replacement records, and arbitrary untyped normalization.
ADR-0012's accepted decision text remains unchanged. The new ADR cites it and
provides the concrete justification it requires for request-local labels.
In the target state, architecture and internal documentation:
- distinguish artifact families from configured stage modules;
- identify the shared reconciliation core and its dependency direction;
- document the generic prompt protocol and domain semantic-policy ownership;
- document the integer candidate protocol and deterministic safety boundary;
- describe the three migrated registry normalizers accurately; and
- remove descriptions of contextual-selector response copying.
Until then, this roadmap remains the canonical description of the proposed
behavior; current-behavior documents must not describe it as implemented.
## Non-Goals
This work does not include:
- a new pipeline stage or a compound module that combines extraction and
normalization;
- a universal configured normalizer for arbitrary artifact kinds or untyped
JSON;
- a physical reorganization of every D&D package around artifact-family
directories;
- operator-configurable arbitrary prompt assets or replacement of core safety
instructions;
- naive batching, cross-batch clustering, or unbounded reconciliation inputs;
- semantic deduplication of event artifacts whose identity dimensions are
already handled deterministically;
- alternate non-source context providers;
- changes to durable D&D artifact schemas or public module keys; or
- live provider calls in the default test suite.
## Completion Criteria
The feature is complete when:
- one domain-neutral core owns candidate IDs, source context, prompt execution,
response assessment, retry classification, and common safe application
mechanics;
- the private response schema uses only integer candidate handles for member
and canonical selection;
- the generic safety protocol and conservative semantic default exist as
shared assets;
- domain adapters can explicitly select the generic semantic default or a
domain-owned semantic fragment without replacing core safety behavior;
- NPC, item, and location registry normalizers use the shared core and retain
their typed domain behavior and durable contracts;
- the model is not required to reproduce names, evidence ranges, durable IDs,
or replacement records in reconciliation output;
- invalid proposals cannot partially mutate or partially collapse an artifact;
- oversized inputs preserve deterministic results rather than being naively
divided;
- the superseded D&D-specific response and reconciliation path is removed;
- the new ADR and current-behavior documentation accurately reflect the
implemented boundary; and
- focused package tests and the repository-wide Go test and vet suites pass.

View File

@@ -189,37 +189,36 @@ func Prepare(document *source.SourceDocument, candidates []Candidate, limits Lim
return result, nil return result, nil
} }
candidateContent, err := json.Marshal(candidateInput{Candidates: views}) candidateContent, withinLimit, err := marshalCandidateInput(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)
} }
if len(candidateContent) > limits.MaximumMaterialBytes { if !withinLimit {
result.disposition = LimitExceeded result.disposition = LimitExceeded
return result, nil return result, nil
} }
intervals := make([]sourceInterval, 0) contextIntervals := make([]sourceInterval, 0)
cited := make([]bool, len(document.Units)) citedIntervals := make([]sourceInterval, 0)
for _, candidate := range prepared { for _, candidate := range prepared {
for _, interval := range candidate.intervals { for _, interval := range candidate.intervals {
for position := interval.start; position <= interval.end; position++ { citedIntervals = append(citedIntervals, interval)
cited[position] = true contextIntervals = append(contextIntervals, sourceInterval{
}
intervals = append(intervals, sourceInterval{
start: max(0, interval.start-limits.ContextRadius), start: max(0, interval.start-limits.ContextRadius),
end: min(len(document.Units)-1, interval.end+limits.ContextRadius), end: min(len(document.Units)-1, interval.end+limits.ContextRadius),
}) })
} }
} }
windows, err := buildContextWindows(document.Units, coalesceIntervals(intervals), cited) transcriptContent, withinLimit, err := marshalTranscriptInput(
document.Units,
coalesceIntervals(contextIntervals),
coalesceIntervals(citedIntervals),
limits.MaximumMaterialBytes-len(candidateContent),
)
if err != nil { if err != nil {
return Preparation{}, fmt.Errorf("prepare semantic reconciliation: build transcript material: invalid source metadata") return Preparation{}, fmt.Errorf("prepare semantic reconciliation: build transcript material: invalid source metadata")
} }
transcriptContent, err := json.Marshal(transcriptInput{Windows: windows}) if !withinLimit {
if err != nil {
return Preparation{}, fmt.Errorf("prepare semantic reconciliation: encode transcript material: %w", err)
}
if len(transcriptContent) > limits.MaximumMaterialBytes-len(candidateContent) {
result.disposition = LimitExceeded result.disposition = LimitExceeded
return result, nil return result, nil
} }
@@ -303,27 +302,93 @@ func coalesceIntervals(intervals []sourceInterval) []sourceInterval {
return coalesced return coalesced
} }
func buildContextWindows(units []source.SourceUnit, intervals []sourceInterval, cited []bool) ([]transcriptWindow, error) { func marshalCandidateInput(candidates []visibleCandidate, maximumBytes int) ([]byte, bool, error) {
windows := make([]transcriptWindow, 0, len(intervals)) content := make([]byte, 0, min(maximumBytes, 4096))
for _, interval := range intervals { var withinLimit bool
window := transcriptWindow{Units: make([]transcriptUnit, 0, interval.end-interval.start+1)} content, withinLimit = appendWithinLimit(content, maximumBytes, []byte(`{"candidates":[`))
if !withinLimit {
return nil, false, nil
}
for index, candidate := range candidates {
encoded, err := json.Marshal(candidate)
if err != nil {
return nil, false, err
}
separator := []byte(nil)
if index > 0 {
separator = []byte(",")
}
content, withinLimit = appendWithinLimit(content, maximumBytes, separator, encoded)
if !withinLimit {
return nil, false, nil
}
}
content, withinLimit = appendWithinLimit(content, maximumBytes, []byte("]}"))
return content, withinLimit, nil
}
// marshalTranscriptInput retains at most maximumBytes while visiting source
// units in order. It deliberately serializes one unit at a time so an oversized
// request does not require a document-sized transcript copy before rejection.
func marshalTranscriptInput(units []source.SourceUnit, contextIntervals, citedIntervals []sourceInterval, maximumBytes int) ([]byte, bool, error) {
content := make([]byte, 0, min(maximumBytes, 4096))
content, withinLimit := appendWithinLimit(content, maximumBytes, []byte(`{"windows":[`))
if !withinLimit {
return nil, false, nil
}
citedIndex := 0
for windowIndex, interval := range contextIntervals {
separator := []byte(nil)
if windowIndex > 0 {
separator = []byte(",")
}
content, withinLimit = appendWithinLimit(content, maximumBytes, separator, []byte(`{"units":[`))
if !withinLimit {
return nil, false, nil
}
for position := interval.start; position <= interval.end; position++ { for position := interval.start; position <= interval.end; position++ {
for citedIndex < len(citedIntervals) && citedIntervals[citedIndex].end < position {
citedIndex++
}
cited := citedIndex < len(citedIntervals) && citedIntervals[citedIndex].start <= position
unit := units[position] unit := units[position]
metadata, err := source.CloneMetadata(unit.Metadata) metadata, err := source.CloneMetadata(unit.Metadata)
if err != nil { if err != nil {
return nil, err return nil, false, err
} }
window.Units = append(window.Units, transcriptUnit{ encoded, err := json.Marshal(transcriptUnit{
ID: unit.ID, ID: unit.ID, Kind: unit.Kind, Text: unit.Text, Metadata: metadata, Cited: cited,
Kind: unit.Kind,
Text: unit.Text,
Metadata: metadata,
Cited: cited[position],
}) })
if err != nil {
return nil, false, err
}
separator = nil
if position > interval.start {
separator = []byte(",")
}
content, withinLimit = appendWithinLimit(content, maximumBytes, separator, encoded)
if !withinLimit {
return nil, false, nil
}
}
content, withinLimit = appendWithinLimit(content, maximumBytes, []byte("]}"))
if !withinLimit {
return nil, false, nil
} }
windows = append(windows, window)
} }
return windows, nil content, withinLimit = appendWithinLimit(content, maximumBytes, []byte("]}"))
return content, withinLimit, nil
}
func appendWithinLimit(content []byte, maximumBytes int, parts ...[]byte) ([]byte, bool) {
for _, part := range parts {
if len(content) > maximumBytes || len(part) > maximumBytes-len(content) {
return content, false
}
content = append(content, part...)
}
return content, true
} }
func newInputMaterial(name string, content []byte) contracts.LLMInputMaterial { func newInputMaterial(name string, content []byte) contracts.LLMInputMaterial {

View File

@@ -277,6 +277,38 @@ func TestPrepareEnforcesCandidateLimitBeforeRenderingContext(t *testing.T) {
} }
} }
func TestPrepareStopsRenderingContextWhenMaterialLimitIsExceeded(t *testing.T) {
cycle := map[string]any{}
cycle["self"] = cycle
document := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 1, Text: strings.Repeat("oversized", 100)},
{ID: 2, Text: "must not be inspected"},
}}
candidates := candidatesForEveryUnit(document)
base, err := Prepare(document, candidates, Limits{
ContextRadius: 0,
MaximumCandidates: len(candidates),
MaximumMaterialBytes: 10000,
})
if err != nil || base.Disposition() != Ready {
t.Fatalf("Prepare(base) = disposition %v, error %v", base.Disposition(), err)
}
candidateBytes := len(base.Materials()[candidateInputName].Content)
document.Units[1].Metadata = cycle
limited, err := Prepare(document, candidates, Limits{
ContextRadius: 0,
MaximumCandidates: len(candidates),
MaximumMaterialBytes: candidateBytes + 64,
})
if err != nil {
t.Fatalf("Prepare(limited) error = %v; rendering should stop at the material bound", err)
}
if limited.Disposition() != LimitExceeded || len(limited.Materials()) != 0 {
t.Fatalf("Prepare(limited) = disposition %v, materials %#v, want bounded skip", limited.Disposition(), limited.Materials())
}
}
func TestPrepareAcceptsExactCombinedByteLimitAndSkipsOneOver(t *testing.T) { func TestPrepareAcceptsExactCombinedByteLimitAndSkipsOneOver(t *testing.T) {
document := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{ document := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 1, Text: "one"}, {ID: 1, Text: "one"},

View File

@@ -102,11 +102,14 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("context error before normalize: %w", err) return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("context error before normalize: %w", err)
} }
if req.Source == nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("source document must not be nil")
}
order := shared.NewSourceRefOrder(req.Source) order := shared.NewSourceRefOrder(req.Source)
records, warnings := preprocessRecords(req.MergeOutput.Value, order) records, warnings := preprocessRecords(req.MergeOutput.Value, order)
deterministic := recordList(records) deterministic := recordList(records)
if len(records) < 2 || req.Source == nil { if len(records) < 2 {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
} }

View File

@@ -47,6 +47,16 @@ func TestModuleContractAndMetadata(t *testing.T) {
} }
} }
func TestNormalizeRejectsNilSourceDocument(t *testing.T) {
_, err := newNormalizer(t, &recordingNormalizerClient{}).Normalize(
context.Background(),
contracts.TypedNormalizeRequest[dnd.ItemRegistry]{},
)
if err == nil || !strings.Contains(err.Error(), "source document must not be nil") {
t.Fatalf("Normalize() error = %v, want nil source rejection", err)
}
}
func TestNormalizeConsolidatesEqualNamesAcrossEvidenceWithoutMutation(t *testing.T) { func TestNormalizeConsolidatesEqualNamesAcrossEvidenceWithoutMutation(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{ doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 1, Text: "The rope is secured."}, {ID: 1, Text: "The rope is secured."},
@@ -380,7 +390,10 @@ func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normaliz
return normalizer return normalizer
} }
func normalizeRequest(value dnd.ItemRegistry) contracts.TypedNormalizeRequest[dnd.ItemRegistry] { func normalizeRequest(value dnd.ItemRegistry) contracts.TypedNormalizeRequest[dnd.ItemRegistry] {
return contracts.TypedNormalizeRequest[dnd.ItemRegistry]{MergeOutput: contracts.MergeArtifact[dnd.ItemRegistry]{Value: value}} return contracts.TypedNormalizeRequest[dnd.ItemRegistry]{
Source: &source.SourceDocument{},
MergeOutput: contracts.MergeArtifact[dnd.ItemRegistry]{Value: value},
}
} }
func normalizeRequestWithSource(value dnd.ItemRegistry, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.ItemRegistry] { func normalizeRequestWithSource(value dnd.ItemRegistry, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.ItemRegistry] {
request := normalizeRequest(value) request := normalizeRequest(value)

View File

@@ -103,11 +103,14 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("context error before normalize: %w", err) return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("context error before normalize: %w", err)
} }
if req.Source == nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("source document must not be nil")
}
order := shared.NewSourceRefOrder(req.Source) order := shared.NewSourceRefOrder(req.Source)
records, warnings := preprocessRecords(req.MergeOutput.Value, order) records, warnings := preprocessRecords(req.MergeOutput.Value, order)
deterministic := recordList(records) deterministic := recordList(records)
if len(records) < 2 || req.Source == nil { if len(records) < 2 {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil return contracts.TypedNormalizeResult[dnd.LocationRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
} }

View File

@@ -44,6 +44,16 @@ func TestModuleContractAndMetadata(t *testing.T) {
} }
} }
func TestNormalizeRejectsNilSourceDocument(t *testing.T) {
_, err := newNormalizer(t, &recordingLocationNormalizerClient{}).Normalize(
context.Background(),
contracts.TypedNormalizeRequest[dnd.LocationRegistry]{},
)
if err == nil || !strings.Contains(err.Error(), "source document must not be nil") {
t.Fatalf("Normalize() error = %v, want nil source rejection", err)
}
}
func TestNormalizePreparesOnlyExactDuplicatesAndRetainsSameNameAndNestedPlaces(t *testing.T) { func TestNormalizePreparesOnlyExactDuplicatesAndRetainsSameNameAndNestedPlaces(t *testing.T) {
input := dnd.LocationRegistry{Locations: []dnd.Location{ input := dnd.LocationRegistry{Locations: []dnd.Location{
{Name: " The Tavern ", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}, {Name: " The Tavern ", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}},

View File

@@ -41,7 +41,10 @@ func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normaliz
return normalizer return normalizer
} }
func normalizeRequest(value dnd.LocationRegistry) contracts.TypedNormalizeRequest[dnd.LocationRegistry] { func normalizeRequest(value dnd.LocationRegistry) contracts.TypedNormalizeRequest[dnd.LocationRegistry] {
return contracts.TypedNormalizeRequest[dnd.LocationRegistry]{MergeOutput: contracts.MergeArtifact[dnd.LocationRegistry]{Value: value}} return contracts.TypedNormalizeRequest[dnd.LocationRegistry]{
Source: &source.SourceDocument{},
MergeOutput: contracts.MergeArtifact[dnd.LocationRegistry]{Value: value},
}
} }
func normalizeRequestWithSource(value dnd.LocationRegistry, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.LocationRegistry] { func normalizeRequestWithSource(value dnd.LocationRegistry, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.LocationRegistry] {
request := normalizeRequest(value) request := normalizeRequest(value)

View File

@@ -102,11 +102,14 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("context error before normalize: %w", err) return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("context error before normalize: %w", err)
} }
if req.Source == nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("source document must not be nil")
}
order := shared.NewSourceRefOrder(req.Source) order := shared.NewSourceRefOrder(req.Source)
records, warnings := preprocessRecords(req.MergeOutput.Value, order) records, warnings := preprocessRecords(req.MergeOutput.Value, order)
deterministic := recordList(records) deterministic := recordList(records)
if len(records) < 2 || req.Source == nil { if len(records) < 2 {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
} }

View File

@@ -126,6 +126,9 @@ func TestNormalizePreservesInvalidCandidatesAndDoesNotAliasInput(t *testing.T) {
func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) { func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) {
normalizer := newNormalizer(t, &recordingNPCNormalizerClient{}) normalizer := newNormalizer(t, &recordingNPCNormalizerClient{})
if _, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCRegistry]{}); err == nil || !strings.Contains(err.Error(), "source document must not be nil") {
t.Fatalf("nil source Normalize() error = %v", err)
}
result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCRegistry{NPCs: nil})) result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCRegistry{NPCs: nil}))
if err != nil || result.Value.NPCs != nil { if err != nil || result.Value.NPCs != nil {
t.Fatalf("nil list result = %#v, error = %v", result.Value, err) t.Fatalf("nil list result = %#v, error = %v", result.Value, err)
@@ -177,7 +180,10 @@ func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normaliz
} }
func normalizeRequest(value dnd.NPCRegistry) contracts.TypedNormalizeRequest[dnd.NPCRegistry] { func normalizeRequest(value dnd.NPCRegistry) contracts.TypedNormalizeRequest[dnd.NPCRegistry] {
return contracts.TypedNormalizeRequest[dnd.NPCRegistry]{MergeOutput: contracts.MergeArtifact[dnd.NPCRegistry]{Value: value}} return contracts.TypedNormalizeRequest[dnd.NPCRegistry]{
Source: &source.SourceDocument{},
MergeOutput: contracts.MergeArtifact[dnd.NPCRegistry]{Value: value},
}
} }
func normalizeRequestWithSource(value dnd.NPCRegistry, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.NPCRegistry] { func normalizeRequestWithSource(value dnd.NPCRegistry, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.NPCRegistry] {