Compare commits

...

13 Commits

73 changed files with 4459 additions and 1761 deletions

View File

@@ -1,47 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.dnd.entity_reconcile.llm",
"type": "object",
"additionalProperties": false,
"required": ["duplicate_groups"],
"properties": {
"duplicate_groups": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["members", "canonical"],
"properties": {
"members": {
"type": "array",
"items": {"$ref": "#/$defs/selector"}
},
"canonical": {"$ref": "#/$defs/selector"}
}
}
}
},
"$defs": {
"selector": {
"type": "object",
"additionalProperties": false,
"required": ["name", "source_refs"],
"properties": {
"name": {"type": "string", "minLength": 1},
"source_refs": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["start_unit_id", "end_unit_id"],
"properties": {
"start_unit_id": {"type": "integer", "minimum": 1},
"end_unit_id": {"type": "integer", "minimum": 1}
}
}
}
}
}
}
}

View File

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

View File

@@ -12,19 +12,19 @@ messages:
- role: system
content_file: ./sharedassets/common-dnd-system.md
- role: user
content_file: ./instructions.md
content_file: ./sharedassets/protocol.md
- role: user
content_file: ./sharedassets/common-dnd-entity-reconciliation.md
content_file: ./instructions.md
cache_control:
type: ephemeral
- role: user
content_file: ./candidates.md
content_file: ./sharedassets/candidates.md
- role: user
content_file: ./sharedassets/common-dnd-transcript-windows.md
content_file: ./sharedassets/transcript-windows.md
cache_control:
type: ephemeral
output:
format: json
validation_mode: json_schema
schema_path: dnd_entity_reconcile_llm.v1.json
schema_path: semantic_reconciliation_llm.v1.json
repair_attempts: 0

View File

@@ -1,2 +0,0 @@
Location candidates:
{{ input "candidates" }}

View File

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

View File

@@ -12,19 +12,19 @@ messages:
- role: system
content_file: ./sharedassets/common-dnd-system.md
- role: user
content_file: ./instructions.md
content_file: ./sharedassets/protocol.md
- role: user
content_file: ./sharedassets/common-dnd-entity-reconciliation.md
content_file: ./instructions.md
cache_control:
type: ephemeral
- role: user
content_file: ./candidates.md
content_file: ./sharedassets/candidates.md
- role: user
content_file: ./sharedassets/common-dnd-transcript-windows.md
content_file: ./sharedassets/transcript-windows.md
cache_control:
type: ephemeral
output:
format: json
validation_mode: json_schema
schema_path: dnd_entity_reconcile_llm.v1.json
schema_path: semantic_reconciliation_llm.v1.json
repair_attempts: 0

View File

@@ -1,3 +0,0 @@
NPC candidates for identity comparison:
{{ input "candidates" }}

View File

@@ -1,6 +1,7 @@
Use candidate aliases and their cited transcript windows to determine whether
candidates refer to the same individual. Preserve distinct individuals even
when their names are similar.
Determine whether candidates refer to the same individual using their
contextual labels and cited transcript windows. Preserve distinct individuals
even when their names are similar or their contextual descriptions are
identical.
When selecting a canonical display name, prefer a complete, stable proper name
over an abbreviation. Prefer an unadorned proper name over that name plus a

View File

@@ -12,19 +12,19 @@ messages:
- role: system
content_file: ./sharedassets/common-dnd-system.md
- role: user
content_file: ./instructions.md
content_file: ./sharedassets/protocol.md
- role: user
content_file: ./sharedassets/common-dnd-entity-reconciliation.md
content_file: ./instructions.md
cache_control:
type: ephemeral
- role: user
content_file: ./candidates.md
content_file: ./sharedassets/candidates.md
- role: user
content_file: ./sharedassets/common-dnd-transcript-windows.md
content_file: ./sharedassets/transcript-windows.md
cache_control:
type: ephemeral
output:
format: json
validation_mode: json_schema
schema_path: dnd_entity_reconcile_llm.v1.json
schema_path: semantic_reconciliation_llm.v1.json
repair_attempts: 0

View File

@@ -1,7 +0,0 @@
Identify only well-supported duplicate groups among the supplied candidates.
Return each selected candidate's supplied contextual descriptor exactly: its
`name` and complete ordered `source_refs`. A group must contain at least two
supplied descriptors, and its `canonical` descriptor must be one of its
members. Do not invent names, ranges, records, evidence, or replacement values.
Omit any uncertain or unsafe group.

View File

@@ -1,6 +0,0 @@
Selected Dungeons & Dragons gameplay transcript evidence windows are provided
below. They may be incomplete, non-contiguous, or overlapping. Use them to
evaluate candidate identity, but do not treat absence outside these windows as
evidence.
{{ input "transcript" }}

View File

@@ -1,3 +0,0 @@
Candidates for identity comparison:
{{ input "candidates" }}

View File

@@ -1,5 +0,0 @@
Your task is to identify duplicate entities withi the provided list of candidates.
Please review the listed candidates and their underlying evidence, and determine whether any of the listed candidates refer to the same underlying entity. Preserve distinct candidates that appear to refer to different underlying entities, even when their names are similar.
When selecting a canonical display name, prefer a complete, stable proper name over an abbreviation. Prefer an unadorned proper name over that name plus additional descriptors, unless the underlying evidence stablishes the descriptors as part of the entity's name. A longer display name is not inherently more canonical.

View File

@@ -1,2 +1,3 @@
Item candidates:
Candidate material:
{{ input "candidates" }}

View File

@@ -0,0 +1,5 @@
Identify only high-confidence duplicate entities among the supplied candidates.
Preserve distinct entities even when their names are similar. Treat contextual descriptions and transcript evidence as supporting material, not as permission to merge ambiguous records.
When several records are duplicates, choose as canonical the candidate with the clearest stable identity. Prefer a complete proper name over an abbreviation, and prefer an unadorned proper name over one with incidental descriptors unless the evidence establishes those descriptors as part of the name. A longer name is not inherently more canonical.

View File

@@ -0,0 +1,27 @@
id: generic.semantic_reconciliation
version: "v1"
inputs:
- name: candidates
required: true
content_type: application/json
- name: transcript
required: true
content_type: application/json
messages:
- role: system
content_file: ./system.md
- role: user
content_file: ./protocol.md
- role: user
content_file: ./instructions.md
cache_control:
type: ephemeral
- role: user
content_file: ./candidates.md
- role: user
content_file: ./transcript-windows.md
output:
format: json
validation_mode: json_schema
schema_path: semantic_reconciliation_llm.v1.json
repair_attempts: 0

View File

@@ -0,0 +1,7 @@
Use only the positive integer `candidate_id` values supplied in the candidate material.
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.
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.
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.

View File

@@ -0,0 +1,2 @@
You reconcile structured records that may describe the same underlying entity.
Follow the supplied protocol and return only the requested structured result.

View File

@@ -0,0 +1,3 @@
Transcript evidence windows:
{{ input "transcript" }}

View File

@@ -0,0 +1,33 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.generic.semantic_reconciliation.llm",
"title": "notarius_semantic_reconciliation_llm_v1",
"type": "object",
"additionalProperties": false,
"required": ["duplicate_groups"],
"properties": {
"duplicate_groups": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["candidate_ids", "canonical_candidate_id"],
"properties": {
"candidate_ids": {
"type": "array",
"minItems": 2,
"uniqueItems": true,
"items": {
"type": "integer",
"minimum": 1
}
},
"canonical_candidate_id": {
"type": "integer",
"minimum": 1
}
}
}
}
}
}

View File

@@ -0,0 +1,91 @@
# ADR-0013: Use request-local candidate handles for semantic reconciliation
**Status:** Accepted
**Date:** 2026-08-09
## Context
Several typed normalize stage modules need semantic reconciliation after
deterministic preprocessing: a model can judge whether source-backed candidates
refer to the same underlying entity, while application code remains responsible
for constructing the normalized artifact. Requiring the model to reproduce a
candidate's full contextual selector makes the response larger and introduces
avoidable formatting, ordering, and transcription failure modes.
Reconciliation must preserve the exact typed artifact boundary established by
[ADR-0003](0003-typed-interfaces-with-two-zone-data-model.md), the domain-neutral
framework and concrete-domain dependency direction established by
[ADR-0004](0004-package-modules-by-domain.md), and the distinction in
[ADR-0009](0009-minimal-evidence-grounded-extraction-artifacts.md) between source
evidence and auxiliary identity context. It also needs a concrete, narrowly
scoped application of the request-local-label exception allowed by
[ADR-0012](0012-resolve-opaque-entity-identifiers-deterministically.md).
## Decision
Semantic reconciliation will be a domain-neutral framework mechanism used by
typed normalize stage modules. A consuming artifact family will retain
ownership of its typed records, identity rules, consolidation policy, durable
IDs, and domain warnings; the framework mechanism will not infer those rules
from arbitrary data.
For each reconciliation request, deterministic code will assign every eligible
model-visible candidate a contiguous, one-based integer handle. The model may
receive the candidate's contextual label, source references, and bounded source
context needed to judge identity, but its structured response will identify
candidates only by those supplied handles. A handle is local to one request,
does not represent entity identity, and must never enter a durable artifact or
be used to derive a durable ID.
The model will propose duplicate groups and select one supplied member of each
group as canonical. Deterministic code will resolve the handles through the
retained request mapping, validate the complete proposal, discard unsafe
groups, and apply only validated groups through typed domain-owned policy. The
model will not synthesize replacement records or directly mutate an artifact.
Every reconciliation prompt will combine a mandatory framework-owned protocol
and safety policy with an explicitly selected semantic policy. The semantic
policy may be the conservative generic policy or a domain-owned policy, but it
cannot replace the shared response protocol or deterministic safety boundary.
## Alternatives considered
- Return durable application IDs. Opaque IDs do not help semantic judgment,
expose application identity mechanics, and make model output reproduce data
that deterministic code already owns.
- Return names alone or copied contextual selectors. Names can be ambiguous,
while reproducing labels and source ranges adds response complexity and
creates mismatches without adding semantic information. Request-local
handles preserve exact selection without either failure mode.
- Ask the model to return synthesized canonical replacement records. This
would transfer typed artifact construction, provenance consolidation, and
durable identity policy to a probabilistic boundary.
- Reconcile reflection-discovered fields or arbitrary JSON. This would weaken
the typed artifact contract and move domain semantics into generic code.
- Hide reconciliation inside extraction or another stage. This would obscure
stage ownership and create cross-stage behavior outside the fixed pipeline;
reconciliation remains explicit normalize-stage behavior.
- Let each domain replace the complete prompt protocol. This would duplicate
safety mechanics and allow domain policy to bypass the common response and
validation contract.
## Consequences
Model responses become smaller and easier to validate, while deterministic
application code retains authority over identity, provenance, ordering, and
typed artifact construction. The framework requires a request-local mapping,
bounded context preparation, a private integer response contract, proposal
assessment, and shared prompt assets. Each consuming artifact family still
requires a typed adapter for its irreducibly domain-specific rules.
Request-local handles are deliberately unsuitable for persistence, logging as
entity identity, checkpoint contracts, or cross-request correlation. Changes
to shared protocol and policy assets must participate in the normal prompt,
schema, and checkpoint fingerprint mechanisms.
Acceptance of this decision does not imply that the shared mechanism or its
consumer migrations are implemented. The
[feature roadmap](../roadmap/semantic-reconciliation.md) owns target behavior
and status, and the
[implementation plan](../roadmap/implementation.md) owns delivery sequence
until the work is complete.

View File

@@ -84,14 +84,15 @@ replace it with a complete profile of the same ID from the configured PromptKit
source. Deployment profile selection is documented in
[Configuration](../config.md#promptkit-profiles).
The transcript assets have distinct consumers. Scene chunking consumes the
complete-session `common-dnd-transcript-full.md`; extraction prompts consume
the current-chunk `common-dnd-transcript-chunk.md`; and NPC, location, and item
normalization consume `common-dnd-transcript-windows.md` alongside their
candidate collections. Player, party, glossary, and compatible campaign
references provide disambiguating context only when declared by the active
prompt; they never establish evidence. Reference material is canonically
ordered before rendering so equivalent inputs remain stable.
The D&D transcript assets have distinct consumers. Scene chunking consumes the
complete-session `common-dnd-transcript-full.md`, while extraction prompts
consume the current-chunk `common-dnd-transcript-chunk.md`. NPC, location, and
item normalization instead mount the generic semantic-reconciliation
candidate and transcript-window presentation assets. Player, party, glossary,
and compatible campaign references provide disambiguating context only when
declared by the active prompt; they never establish evidence. Reference
material is canonically ordered before rendering so equivalent inputs remain
stable.
Extraction prompts render the common system and identity messages first, then
cached campaign references and the cached chunk transcript. Evidence policy and
@@ -101,10 +102,11 @@ reusable extraction prefix identical while preserving the lane-specific suffix.
Scene chunking intentionally uses a different order: system, cached campaign
references, uncached module instructions, then the final ephemeral full
transcript. Entity normalization also has its own order: system, uncached
module instructions, ephemeral reconciliation policy, uncached candidates, and
final ephemeral transcript windows. These orders and cache controls are prompt
behavior; change them only through the owning manifest and prompt declaration.
transcript. Entity normalization also has its own order: D&D system, mandatory
generic protocol, ephemeral domain semantic instructions, generic candidate
presentation, and final ephemeral generic transcript windows. These orders and
cache controls are prompt behavior; change them only through the owning
manifest and prompt declaration.
## Evidence, Candidates, And Normalization
@@ -134,12 +136,46 @@ canonicalize display values and evidence, use source-document order for stable
output, and issue bounded warnings for changes or collapsed duplicates. NPC,
item, and location registry normalizers are intentional exceptions: each first
produces a deterministic candidate set, then may use a bounded structured-LLM
proposal to reconcile identity groups. The proposal selects supplied
descriptors—names with their candidate source references—not durable IDs.
Request-local candidate keys may support resolution internally, but are never
included in model input or output. Colliding descriptors are ineligible, and
invalid or unusable proposals retain the deterministic result with retry or
fallback diagnostics; the model does not directly replace durable records.
proposal to reconcile identity groups.
## Semantic Registry Reconciliation
The three registry normalizers instantiate the domain-neutral
`internal/framework/semanticreconcile` engine with default bounds. Each
eligible candidate receives a contiguous, one-based `candidate_id` for that
request. The model sees that handle, the candidate label and source-free
evidence ranges, plus bounded transcript windows; it returns only duplicate
groups of supplied handles and one supplied canonical handle per group. It
never returns names, evidence, durable IDs, or replacement records. Identical
labels and evidence remain independently selectable because their handles are
distinct.
The generic core owns the mandatory handle protocol, candidate and transcript
presentation, the private response schema, source-reference validation,
candidate and combined-material limits, structured completion, proposal
assessment, stable group ordering, and typed plan-application mechanics. The
D&D prompt contributes its system message and registry-specific semantic
instructions. The generic registrar registers the shared prompt and schema;
the D&D registrar registers each consuming prompt and the fallback profile.
Fewer than two eligible candidates skips the LLM without a semantic warning.
An exceeded bound also skips the call and preserves the deterministic
preprocessed registry, adding the registry's bounded fallback warning. Invalid
structured output or discarded proposal groups use the normalizer's existing
retry contract; retry exhaustion preserves the safe deterministic or
partially applied result and emits its bounded fallback warning. Provider,
transport, cancellation, and context-material failures remain execution
errors.
Application remains typed and registry-owned. All three policies select the
canonical member's normalized display name, union member evidence in source
order, preserve ungrouped records, and derive durable identity only after
consolidation. NPC IDs derive from the final name. Item IDs also derive from
the final name, and a typed guard prevents currency aliases from crossing
denominations or mixing currency with non-currency records. Location IDs
derive from the final name and final evidence, preserving same-name,
parent/child, and distinct physical-place identities. Registry warning scopes,
reason codes, and postconditions remain outside the generic core.
## Generated References And Grounding

View File

@@ -142,12 +142,28 @@ arrangement and its data-only boundary are defined by
[ADR-0011](../adr/0011-centralize-llm-assets.md), rather than by this runtime
guide.
The generic registrar is the sole production registration owner for the
semantic-reconciliation default prompt and private response schema. The
domain-neutral reconciliation package also exposes only its mandatory protocol
and candidate/transcript presentation files for domain prompt manifests. D&D
registry normalizers mount those files while retaining ownership and hashing
of their D&D system message, semantic instructions, and complete prompt
declaration. The response schema is therefore registered once even though
several typed normalizers select it.
Mounted prompt assets determine a module's fingerprint. The fingerprint hashes
only the module and shared files explicitly selected by its manifest, so an
unrelated asset does not invalidate a checkpoint. Schema loaders validate JSON,
attach identity and digest metadata, make defensive copies, and expose
diagnostics without raw schema bytes.
Semantic-reconciliation normalizers extend this identity with the shared
response-schema digest, framework policy version, and complete limit-policy
digest. Their manifest metadata records the same content-free prompt, schema,
policy, and limit identities together with domain identity and normalization
policies. Request-local handles, source material, proposal content, and raw
asset bytes are not checkpoint metadata.
Private response schemas validate a model transport envelope. They are not the
durable artifact schema and should not be documented as an external wire
contract. Durable formats and compatibility rules remain in the

View File

@@ -44,12 +44,20 @@ capability during preparation and the output boundary owns publication. See
the [Published Evidence Context contract](../integrations/evidence-context.md)
for the durable result.
An artifact family is broader than a module: it owns the cohesive domain
feature across its artifact type, codec, stage modules, validators, prompt
policy, schemas, identity helpers, and reference projections. An extractor and
normalizer in one artifact family remain independently registered modules in
their respective pipeline stages. This ownership vocabulary does not create a
new registry or change the fixed pipeline.
## Production Composition
Production composition is intentionally split by family:
- The generic registrar provides the unit chunker, generic JSON validators,
and JSON output encoder.
JSON output encoder, and shared semantic-reconciliation prompt and response
schema assets.
- The Seriatim registrar provides the transcript input adapter. Its external
input behavior is defined by the [Seriatim contract](../integrations/seriatim.md).
- The D&D registrar provides its codecs, extractors, mergers, normalizers,
@@ -60,6 +68,36 @@ The CLI owns the composition that invokes these registrars. A module package
may register its own family but must not assemble the CLI or make framework
packages depend on production extensions.
## Semantic Reconciliation
`internal/framework/semanticreconcile` is a domain-neutral strategy used by a
typed normalize module; it is not itself a selectable stage module. A
source-backed artifact-family normalizer projects its deterministic records
into contextual candidates and owned typed record envelopes, supplies its
chosen prompt identity and resolved LLM profile, and constructs an engine with
explicit limits. The core filters invalid evidence, assigns contiguous
request-local integer handles, renders bounded candidate and transcript
materials, invokes the structured-completion boundary, and assesses the
returned duplicate groups into a stable non-overlapping plan.
The normalizer then applies that plan through a typed `ApplicationPolicy`. The
core preserves ungrouped records, contribution order, and provenance while the
artifact family owns group guards, field and evidence consolidation, durable
ID derivation, retry and fallback presentation, warnings, and postconditions.
Request-local handles do not enter the typed value or durable artifact. Fewer
than two eligible candidates skips model invocation; exceeding a candidate or
combined-material bound preserves the deterministic result under the family's
fallback policy. Provider, transport, cancellation, and context-construction
failures remain execution errors.
The core supplies a conservative generic prompt and the single private
response schema. A domain prompt may substitute its semantic instructions but
mounts the core-owned protocol and candidate/transcript presentation assets.
Prompt, schema, policy, and limit identities participate in manifest metadata
and checkpoint fingerprints. The generic registrar owns production
registration of those shared assets; a consuming domain registrar owns only
its domain prompt.
## Adding Or Changing A Module
1. Choose the pipeline stage and the typed artifact boundary. Put external

View File

@@ -29,6 +29,7 @@ physical state roots.
| Generic models | **internal/core/source**, **internal/core/artifacts**, **internal/framework/contracts** | Source documents and chunks, manifests and provenance, plus typed artifact, reference, validation, output, and structured-completion contracts. |
| Pipeline framework | **internal/framework/pipeline** | Registries, profile and reference resolution, typed preparation, validation, retry coordination, ordered execution, handoff, and result assembly. |
| LLM and prompt runtime | **internal/framework/llm**, **internal/framework/promptfs** | Provider-neutral structured completions, scheduling, profile recording, prompt assets, schema registration, and credential-shaped-value redaction. |
| Semantic reconciliation | **internal/framework/semanticreconcile** | Bounded source-backed candidate preparation, request-local handle proposals, deterministic assessment, typed plan application, and reconciliation identity metadata; see [Module Internals](modules.md#semantic-reconciliation) and [D&D Module Internals](dnd.md#semantic-registry-reconciliation). |
| Embedded LLM content | **assets** | Read-only centralized LLM-facing content, scoped by its consuming package; see [LLM Runtime](llm.md#prompt-and-schema-assets) and [D&D Module Internals](dnd.md#prompt-construction). |
| Runtime state | **internal/core/fileio**, **internal/core/debugbundle**, **internal/framework/checkpoint**, **internal/framework/chunkplan**, **internal/framework/chunkmap**, **internal/framework/debug** | Confined atomic files, debug bundles, checkpoint and chunk-plan state, accepted chunk maps, and pipeline-facing debug recording. |
| Production extensions | **internal/modules/generic**, **internal/modules/seriatim**, **internal/modules/dnd** | Domain-neutral extensions, Seriatim input support, and D&D extraction families registered into the production catalog. |
@@ -49,8 +50,9 @@ the CLI composition boundary.
composition, and path safety.
- [LLM Runtime](llm.md): structured completion, scheduling, prompt assets,
profiles, and secret handling.
- [Module Internals](modules.md): generic extension registration, module
construction, validation, and reference mechanics.
- [Module Internals](modules.md): generic extension registration, artifact
families, module construction, semantic reconciliation, validation, and
reference mechanics.
- [D&D Module Internals](dnd.md): shared D&D extractor conventions, generated
reference projections, and lane-specific exceptions. Durable D&D and
Seriatim data shapes remain in the [integration contracts](../integrations/).

View File

@@ -24,6 +24,12 @@ DAGs or a general workflow language. Every stage remains explicit; general
chunking, merging, or normalization behavior must not be hidden inside an
extractor.
A stage module is one configured implementation of one pipeline stage. An
artifact family is the cohesive domain feature that owns an artifact across
the explicit stages and supporting codecs, validators, prompts, identity
rules, and reference projections. Artifact-family ownership does not combine
stages or alter the fixed pipeline.
Input and chunking are pipeline-wide. Each selected artifact lane owns its
extract, merge, and normalize stages, and the output stage aggregates the run's
lane outcomes.
@@ -39,6 +45,12 @@ implementations. Domain-neutral model and framework layers provide reusable
policy, contracts, and orchestration. Concrete input, pipeline, output, and
validation extensions depend inward on those generic layers.
Semantic reconciliation is one such domain-neutral framework mechanism. It
prepares bounded source context, invokes a shared model-judgment protocol,
validates proposals, and applies safe plans through typed policies supplied by
the consuming artifact family. It does not own domain identity, durable IDs,
warning semantics, or artifact construction rules.
Generic layers must not depend on production extensions. Concrete extensions
must not compose the application or take ownership of process behavior. The
current packages implementing these layers are inventoried in
@@ -186,8 +198,13 @@ or domain-specific prompt logic.
When a model selects an application entity, callers must supply a contextual
selection and deterministically attach the opaque application identity whenever
the selection resolves exactly. Models do not receive or reproduce opaque
application identifiers; [ADR-0012](../adr/0012-resolve-opaque-entity-identifiers-deterministically.md)
records the rationale and limited request-local-label exception.
application identifiers. Semantic reconciliation may instead expose
contiguous, one-based candidate handles that exist only for one request;
deterministic code resolves them before typed application, and they never
become durable identity. This is the approved request-local-label application
of [ADR-0012](../adr/0012-resolve-opaque-entity-identifiers-deterministically.md)
recorded by
[ADR-0013](../adr/0013-use-request-local-candidate-handles-for-semantic-reconciliation.md).
LLM calls and other external operations accept cancellation and respect
timeouts. Concurrency control belongs in shared runtime plumbing rather than in

View File

@@ -24,28 +24,52 @@ not as committed release dates.
## Shared Normalization And Quality Work
### Generic LLM-Assisted Deduplication
The implemented source-backed core and initial D&D registry adoption are
described by [Module Internals](../internal/modules.md#semantic-reconciliation)
and
[D&D Module Internals](../internal/dnd.md#semantic-registry-reconciliation).
The [Semantic Reconciliation Roadmap](semantic-reconciliation.md) retains the
original feature scope; the sections below keep broader extensions deferred.
- Add a reusable normalizer that asks an LLM to identify duplicate sets in a
list and propose one replacement element for each set.
- Define the minimum domain-neutral input contract, initially an ordered list
whose elements retain stable unique IDs as internal deterministic state.
Model proposals use contextual descriptors, or a specifically justified
request-local short label, rather than durable IDs. Artifact-kind
registrations or adapters may expose that structure without moving domain
rules into the generic package.
- Keep mutation deterministic: parse and validate the model's duplicate groups,
resolve every supplied descriptor or local label exactly, reject overlapping
or malformed groups, prevent unrelated insertion or deletion, and apply only
approved replacement operations in code.
- Preserve provenance needed for audit and downstream validation, and emit
warnings describing every collapsed group.
- Evaluate batching and context-window limits before applying the normalizer to
large artifact collections.
### Large-Collection Semantic Reconciliation
The model may use its own domain knowledge to judge semantic duplication; the
generic implementation is responsible only for the common proposal contract,
safety checks, and deterministic application of accepted changes.
- Evaluate deterministic candidate blocking only after representative registry
inputs exceed the active roadmap's bounded single-request limits. Blocking
should use cheap, explainable signals to form plausible comparison sets while
preserving the possibility that a duplicate appears outside a lexical name
match.
- Define correctness for candidates that appear in more than one block,
conflicting canonical selections, transitive identity across blocks, retry
isolation, and deterministic final ordering before implementation.
- Prefer a reconciliation graph or union plan with explicit conflict checks
over arbitrary fixed-size slices. Never silently treat a batch boundary as
evidence that two candidates are distinct.
- Record per-request bounds, block provenance, model calls, discarded
proposals, and final group derivation well enough to audit a collapse.
### Operator-Selected Semantic Policies
- Consider allowing an operator to select an approved semantic-policy prompt
for a typed reconciliation module without replacing the shared protocol,
response schema, or deterministic safety rules.
- Define the trusted asset source, configuration syntax, compatibility checks,
startup validation, provenance, prompt fingerprinting, checkpoint effects,
and support boundary before exposing the option.
- Prefer selection among registered, typed-policy-compatible prompt assets over
arbitrary filesystem prompt paths. Do not add this flexibility until an
operator workflow requires it; artifact-family-owned policy remains simpler
and safer for the initial implementation.
### Broader Reconciliation Inputs And Module Selection
- Revisit alternate context providers when a concrete non-source-backed entity
collection needs semantic reconciliation. Any extension must preserve the
same request-local identity, deterministic proposal validation, provenance,
and typed application guarantees.
- Consider a selectable generic normalizer only if Notarius gains a real
domain-neutral typed artifact contract that can safely support it. Do not
weaken exact artifact registration or introduce reflection-based arbitrary
JSON mutation merely to expose a universal module key.
### Validation And Review
@@ -102,6 +126,18 @@ checkpoint reuse, when an older artifact may be decoded or adapted, and when a
producer or all dependents must be recomputed. Do not add a general migration
framework until an actual contract change requires one.
### Artifact-family-oriented physical packaging
[ADR-0004](../adr/0004-package-modules-by-domain.md) currently groups production
extensions by domain and then by pipeline stage. After artifact-family
ownership terminology is established and more families span extraction,
normalization, validation, codecs, references, and assets, reassess whether a
feature-first physical layout would improve navigation and reduce scattered
changes enough to justify a repository-wide package migration. Any change must
address Go dependency cycles, registrar ownership, stable public module keys,
and supersession of the affected ADR-0004 decision. Conceptual artifact-family
ownership does not by itself require this move.
## Blue-Sky Platform And Operations
These ideas are intentionally less specified. Promote one into an earlier

View File

@@ -25,6 +25,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
@@ -311,6 +312,47 @@ func TestProductionPromptAssetsPrepareWithoutProviderCredentials(t *testing.T) {
if _, err := pipeline.Prepare(effective.ResolvedPipeline, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil {
t.Fatalf("prepare production scene and spell modules: %v", err)
}
schemaFS, err := components.assets.SchemaFS()
if err != nil {
t.Fatalf("production schema assets: %v", err)
}
if _, err := fs.ReadFile(schemaFS, filepath.Base(semanticreconcile.SchemaAssetPath)); err != nil {
t.Fatalf("generic reconciliation schema asset: %v", err)
}
options, err := components.assets.PromptKitOptions()
if err != nil {
t.Fatalf("production PromptKit options: %v", err)
}
options = append(options, promptkit.WithProfiles(promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
ID: "assembled-prompt-test", Endpoint: "http://127.0.0.1:1/v1", Model: "test",
})))
engine, err := promptkit.NewEngine(promptkit.Config{}, options...)
if err != nil {
t.Fatalf("production prompt engine: %v", err)
}
inputs := map[string]promptkit.ArtifactRef{
"candidates": promptkit.Inline(`{"candidates":[{"candidate_id":1,"label":"Alias","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
"transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`),
}
for _, prompt := range []struct {
id string
version string
}{
{id: npcnormalize.PromptID, version: npcnormalize.PromptVersion},
{id: itemregistrynormalize.PromptID, version: itemregistrynormalize.PromptVersion},
{id: locationnormalize.PromptID, version: locationnormalize.PromptVersion},
} {
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: prompt.id, PromptVersion: prompt.version, ProfileID: "assembled-prompt-test", Inputs: inputs,
})
if err != nil {
t.Fatalf("prepare production prompt %q: %v", prompt.id, err)
}
if prepared.OutputContract.SchemaPath != filepath.Base(semanticreconcile.SchemaAssetPath) {
t.Fatalf("prompt %q schema = %q, want generic reconciliation schema", prompt.id, prepared.OutputContract.SchemaPath)
}
}
}
func TestProductionSpellValidatorsPrepareFromMaterializedCatalog(t *testing.T) {

View File

@@ -0,0 +1,394 @@
package semanticreconcile
import (
"fmt"
"sort"
"strings"
)
// CloneValueFunc returns a value that shares no caller-owned mutable state with
// its input.
type CloneValueFunc[T any] func(T) T
// Record owns one typed value and its deterministic input provenance.
type Record[T any] struct {
value T
originalInputIndexes []int
earliestInputPosition int
cloneValue CloneValueFunc[T]
}
// NewRecord constructs an owned typed record. Original input indexes are
// normalized into ascending unique order.
func NewRecord[T any](value T, originalInputIndexes []int, earliestInputPosition int, cloneValue CloneValueFunc[T]) (Record[T], error) {
if cloneValue == nil {
return Record[T]{}, fmt.Errorf("construct semantic reconciliation record: clone value function must not be nil")
}
if earliestInputPosition < 0 {
return Record[T]{}, fmt.Errorf("construct semantic reconciliation record: earliest input position must not be negative")
}
indexes, err := normalizeInputIndexes(originalInputIndexes)
if err != nil {
return Record[T]{}, fmt.Errorf("construct semantic reconciliation record: %w", err)
}
return Record[T]{
value: cloneValue(value),
originalInputIndexes: indexes,
earliestInputPosition: earliestInputPosition,
cloneValue: cloneValue,
}, nil
}
// Value returns an independently owned typed value.
func (record Record[T]) Value() T {
if record.cloneValue == nil {
var zero T
return zero
}
return record.cloneValue(record.value)
}
// OriginalInputIndexes returns an owned ascending unique index list.
func (record Record[T]) OriginalInputIndexes() []int {
return cloneSlice(record.originalInputIndexes)
}
// EarliestInputPosition returns the earliest deterministic input position
// contributing to this record.
func (record Record[T]) EarliestInputPosition() int { return record.earliestInputPosition }
// RejectionCategory is a stable, domain-neutral reason that an otherwise safe
// semantic group was not applied.
type RejectionCategory string
// ApplicationPolicy supplies only the typed behavior needed to apply a safe
// reconciliation plan. An empty category from RejectGroup accepts the group.
type ApplicationPolicy[T any] struct {
CloneValue CloneValueFunc[T]
RejectGroup func(members []T, canonical T) RejectionCategory
ConsolidateGroup func(members []T, canonical T) (T, error)
}
// GroupProvenance identifies the complete input contribution of one plan
// group without prescribing domain warning or retry policy.
type GroupProvenance struct {
memberPositions []int
canonicalPosition int
originalInputIndexes []int
earliestPosition int
}
// MemberPositions returns owned record positions in ascending order.
func (provenance GroupProvenance) MemberPositions() []int {
return cloneSlice(provenance.memberPositions)
}
// CanonicalPosition returns the plan-selected canonical record position.
func (provenance GroupProvenance) CanonicalPosition() int {
return provenance.canonicalPosition
}
// OriginalInputIndexes returns the sorted union contributed by all members.
func (provenance GroupProvenance) OriginalInputIndexes() []int {
return cloneSlice(provenance.originalInputIndexes)
}
// EarliestInputPosition returns the earliest member input position.
func (provenance GroupProvenance) EarliestInputPosition() int {
return provenance.earliestPosition
}
// AppliedGroup records the provenance of one successfully consolidated group.
type AppliedGroup struct {
provenance GroupProvenance
}
// Provenance returns an independently owned provenance snapshot.
func (event AppliedGroup) Provenance() GroupProvenance {
return cloneGroupProvenance(event.provenance)
}
// RejectedGroup records a typed guard decision while leaving warning and retry
// construction to the consuming domain.
type RejectedGroup struct {
category RejectionCategory
provenance GroupProvenance
}
// Category returns the stable neutral rejection category.
func (event RejectedGroup) Category() RejectionCategory { return event.category }
// Provenance returns an independently owned provenance snapshot.
func (event RejectedGroup) Provenance() GroupProvenance {
return cloneGroupProvenance(event.provenance)
}
// ApplicationResult owns the ordered records and neutral events from one plan
// application.
type ApplicationResult[T any] struct {
records []Record[T]
appliedGroups []AppliedGroup
rejectedGroups []RejectedGroup
}
// Records returns independently owned records in earliest-contribution order.
func (result ApplicationResult[T]) Records() []Record[T] {
return cloneRecords(result.records)
}
// AppliedGroups returns independently owned applied-group events.
func (result ApplicationResult[T]) AppliedGroups() []AppliedGroup {
events := make([]AppliedGroup, len(result.appliedGroups))
for index, event := range result.appliedGroups {
events[index] = AppliedGroup{provenance: cloneGroupProvenance(event.provenance)}
}
return preserveEmptySlice(result.appliedGroups, events)
}
// RejectedGroups returns independently owned rejected-group events.
func (result ApplicationResult[T]) RejectedGroups() []RejectedGroup {
events := make([]RejectedGroup, len(result.rejectedGroups))
for index, event := range result.rejectedGroups {
events[index] = RejectedGroup{category: event.category, provenance: cloneGroupProvenance(event.provenance)}
}
return preserveEmptySlice(result.rejectedGroups, events)
}
type applicationEntry[T any] struct {
record Record[T]
order int
}
// ApplyPlan applies safe, non-overlapping groups without mutating the plan,
// records, or values supplied to policy callbacks.
func ApplyPlan[T any](plan Plan, records []Record[T], policy ApplicationPolicy[T]) (ApplicationResult[T], error) {
if err := validateApplicationPolicy(policy); err != nil {
return ApplicationResult[T]{}, err
}
for index, record := range records {
if err := validateRecord(record); err != nil {
return ApplicationResult[T]{}, fmt.Errorf("apply semantic reconciliation plan: record %d: %w", index, err)
}
}
groups := plan.Groups()
groupByFirstMember, err := validateApplicationPlan(groups, len(records))
if err != nil {
return ApplicationResult[T]{}, err
}
groupedPositions := make(map[int]struct{}, len(records))
for _, group := range groups {
for _, position := range group.memberPositions {
groupedPositions[position] = struct{}{}
}
}
entries := make([]applicationEntry[T], 0, len(records))
result := ApplicationResult[T]{}
for position, record := range records {
group, firstMember := groupByFirstMember[position]
if !firstMember {
if _, grouped := groupedPositions[position]; grouped {
continue
}
entries = append(entries, applicationEntry[T]{record: cloneRecordWith(record, policy.CloneValue), order: position})
continue
}
provenance := groupProvenance(group, records)
guardMembers, guardCanonical := policyInputs(group, records, policy.CloneValue)
category := RejectionCategory("")
if policy.RejectGroup != nil {
category = policy.RejectGroup(guardMembers, guardCanonical)
}
if category != "" && strings.TrimSpace(string(category)) == "" {
return ApplicationResult[T]{}, fmt.Errorf("apply semantic reconciliation group beginning at position %d: rejection category must not be blank", position)
}
if category != "" {
for _, memberPosition := range group.memberPositions {
entries = append(entries, applicationEntry[T]{record: cloneRecordWith(records[memberPosition], policy.CloneValue), order: memberPosition})
}
result.rejectedGroups = append(result.rejectedGroups, RejectedGroup{category: category, provenance: provenance})
continue
}
members, canonical := policyInputs(group, records, policy.CloneValue)
consolidated, err := policy.ConsolidateGroup(members, canonical)
if err != nil {
return ApplicationResult[T]{}, fmt.Errorf("apply semantic reconciliation group beginning at position %d: consolidate: %w", position, err)
}
owned, err := NewRecord(consolidated, provenance.originalInputIndexes, provenance.earliestPosition, policy.CloneValue)
if err != nil {
return ApplicationResult[T]{}, fmt.Errorf("apply semantic reconciliation group beginning at position %d: own consolidated record: %w", position, err)
}
entries = append(entries, applicationEntry[T]{record: owned, order: position})
result.appliedGroups = append(result.appliedGroups, AppliedGroup{provenance: provenance})
}
sort.SliceStable(entries, func(left, right int) bool {
if entries[left].record.earliestInputPosition == entries[right].record.earliestInputPosition {
return entries[left].order < entries[right].order
}
return entries[left].record.earliestInputPosition < entries[right].record.earliestInputPosition
})
if records != nil {
result.records = make([]Record[T], len(entries))
for index, entry := range entries {
result.records[index] = cloneRecord(entry.record)
}
}
return result, nil
}
func validateApplicationPolicy[T any](policy ApplicationPolicy[T]) error {
if policy.CloneValue == nil {
return fmt.Errorf("apply semantic reconciliation plan: clone value function must not be nil")
}
if policy.ConsolidateGroup == nil {
return fmt.Errorf("apply semantic reconciliation plan: consolidate group function must not be nil")
}
return nil
}
func validateRecord[T any](record Record[T]) error {
if record.cloneValue == nil {
return fmt.Errorf("invalid construction state: clone value function must not be nil")
}
if record.earliestInputPosition < 0 {
return fmt.Errorf("invalid construction state: earliest input position must not be negative")
}
for index, value := range record.originalInputIndexes {
if value < 0 {
return fmt.Errorf("invalid construction state: original input index must not be negative")
}
if index > 0 && record.originalInputIndexes[index-1] >= value {
return fmt.Errorf("invalid construction state: original input indexes must be ascending and unique")
}
}
return nil
}
func validateApplicationPlan(groups []PlanGroup, recordCount int) (map[int]PlanGroup, error) {
groupByFirstMember := make(map[int]PlanGroup, len(groups))
used := make(map[int]struct{})
for groupIndex, group := range groups {
if len(group.memberPositions) < 2 {
return nil, fmt.Errorf("apply semantic reconciliation plan: group %d must contain at least two member positions", groupIndex)
}
canonicalMember := false
for memberIndex, position := range group.memberPositions {
if position < 0 || position >= recordCount {
return nil, fmt.Errorf("apply semantic reconciliation plan: group %d member position %d is outside record range [0,%d)", groupIndex, position, recordCount)
}
if memberIndex > 0 && group.memberPositions[memberIndex-1] >= position {
return nil, fmt.Errorf("apply semantic reconciliation plan: group %d member positions must be ascending and unique", groupIndex)
}
if _, exists := used[position]; exists {
return nil, fmt.Errorf("apply semantic reconciliation plan: record position %d belongs to multiple groups", position)
}
used[position] = struct{}{}
canonicalMember = canonicalMember || position == group.canonicalPosition
}
if group.canonicalPosition < 0 || group.canonicalPosition >= recordCount {
return nil, fmt.Errorf("apply semantic reconciliation plan: group %d canonical position %d is outside record range [0,%d)", groupIndex, group.canonicalPosition, recordCount)
}
if !canonicalMember {
return nil, fmt.Errorf("apply semantic reconciliation plan: group %d canonical position %d is not a member", groupIndex, group.canonicalPosition)
}
groupByFirstMember[group.memberPositions[0]] = group
}
return groupByFirstMember, nil
}
func groupProvenance[T any](group PlanGroup, records []Record[T]) GroupProvenance {
provenance := GroupProvenance{
memberPositions: cloneSlice(group.memberPositions),
canonicalPosition: group.canonicalPosition,
earliestPosition: records[group.memberPositions[0]].earliestInputPosition,
}
for _, position := range group.memberPositions {
provenance.originalInputIndexes = append(provenance.originalInputIndexes, records[position].originalInputIndexes...)
if records[position].earliestInputPosition < provenance.earliestPosition {
provenance.earliestPosition = records[position].earliestInputPosition
}
}
provenance.originalInputIndexes, _ = normalizeInputIndexes(provenance.originalInputIndexes)
return provenance
}
func policyInputs[T any](group PlanGroup, records []Record[T], cloneValue CloneValueFunc[T]) ([]T, T) {
members := make([]T, len(group.memberPositions))
for index, position := range group.memberPositions {
members[index] = cloneValue(records[position].value)
}
return members, cloneValue(records[group.canonicalPosition].value)
}
func normalizeInputIndexes(indexes []int) ([]int, error) {
if indexes == nil {
return nil, nil
}
normalized := append([]int{}, indexes...)
for _, index := range normalized {
if index < 0 {
return nil, fmt.Errorf("original input index must not be negative")
}
}
sort.Ints(normalized)
write := 0
for _, index := range normalized {
if write > 0 && normalized[write-1] == index {
continue
}
normalized[write] = index
write++
}
return normalized[:write], nil
}
func cloneRecord[T any](record Record[T]) Record[T] {
return cloneRecordWith(record, record.cloneValue)
}
func cloneRecordWith[T any](record Record[T], cloneValue CloneValueFunc[T]) Record[T] {
return Record[T]{
value: cloneValue(record.value),
originalInputIndexes: cloneSlice(record.originalInputIndexes),
earliestInputPosition: record.earliestInputPosition,
cloneValue: cloneValue,
}
}
func cloneRecords[T any](records []Record[T]) []Record[T] {
if records == nil {
return nil
}
cloned := make([]Record[T], len(records))
for index, record := range records {
cloned[index] = cloneRecord(record)
}
return cloned
}
func cloneGroupProvenance(provenance GroupProvenance) GroupProvenance {
return GroupProvenance{
memberPositions: cloneSlice(provenance.memberPositions),
canonicalPosition: provenance.canonicalPosition,
originalInputIndexes: cloneSlice(provenance.originalInputIndexes),
earliestPosition: provenance.earliestPosition,
}
}
func cloneSlice[T any](values []T) []T {
if values == nil {
return nil
}
return append([]T{}, values...)
}
func preserveEmptySlice[S ~[]E, E any](source S, cloned []E) []E {
if source == nil {
return nil
}
return cloned
}

View File

@@ -0,0 +1,376 @@
package semanticreconcile
import (
"errors"
"reflect"
"strings"
"testing"
)
type syntheticRecord struct {
Name string
Notes []string
}
func cloneSyntheticRecord(record syntheticRecord) syntheticRecord {
record.Notes = cloneSlice(record.Notes)
return record
}
func TestNewRecordOwnsValueAndNormalizesProvenance(t *testing.T) {
value := syntheticRecord{Name: "alpha", Notes: []string{"owned"}}
indexes := []int{3, 1, 3}
record, err := NewRecord(value, indexes, 4, cloneSyntheticRecord)
if err != nil {
t.Fatalf("NewRecord() error = %v", err)
}
value.Notes[0] = "caller mutation"
indexes[0] = 99
gotValue := record.Value()
gotIndexes := record.OriginalInputIndexes()
if gotValue.Name != "alpha" || !reflect.DeepEqual(gotValue.Notes, []string{"owned"}) {
t.Fatalf("Value() = %#v, want owned original", gotValue)
}
if !reflect.DeepEqual(gotIndexes, []int{1, 3}) {
t.Fatalf("OriginalInputIndexes() = %v, want [1 3]", gotIndexes)
}
if record.EarliestInputPosition() != 4 {
t.Fatalf("EarliestInputPosition() = %d, want 4", record.EarliestInputPosition())
}
gotValue.Notes[0] = "accessor mutation"
gotIndexes[0] = 88
if again := record.Value(); again.Notes[0] != "owned" {
t.Fatalf("Value() retained accessor mutation: %#v", again)
}
if again := record.OriginalInputIndexes(); !reflect.DeepEqual(again, []int{1, 3}) {
t.Fatalf("OriginalInputIndexes() retained accessor mutation: %v", again)
}
}
func TestNewRecordPreservesNilAndEmptyIndexOwnership(t *testing.T) {
nilIndexes, err := NewRecord(syntheticRecord{}, nil, 0, cloneSyntheticRecord)
if err != nil {
t.Fatalf("NewRecord(nil) error = %v", err)
}
emptyIndexes, err := NewRecord(syntheticRecord{}, []int{}, 0, cloneSyntheticRecord)
if err != nil {
t.Fatalf("NewRecord(empty) error = %v", err)
}
if nilIndexes.OriginalInputIndexes() != nil {
t.Fatal("nil original indexes became non-nil")
}
if got := emptyIndexes.OriginalInputIndexes(); got == nil || len(got) != 0 {
t.Fatalf("empty original indexes = %#v, want non-nil empty", got)
}
if _, err := NewRecord(syntheticRecord{}, nil, 0, CloneValueFunc[syntheticRecord](nil)); err == nil {
t.Fatal("NewRecord() accepted nil clone function")
}
if _, err := NewRecord(syntheticRecord{}, nil, -1, cloneSyntheticRecord); err == nil {
t.Fatal("NewRecord() accepted negative earliest position")
}
if _, err := NewRecord(syntheticRecord{}, []int{-1}, 0, cloneSyntheticRecord); err == nil {
t.Fatal("NewRecord() accepted negative original input index")
}
}
func TestApplyPlanConsolidatesGroupsAndOrdersByEarliestContribution(t *testing.T) {
records := syntheticRecords(t,
recordFixture{"alpha", []int{4}, 4},
recordFixture{"bravo", []int{3, 1}, 1},
recordFixture{"charlie", []int{2}, 2},
recordFixture{"delta", []int{2, 0}, 0},
)
plan := Plan{groups: []PlanGroup{
{memberPositions: []int{0, 2}, canonicalPosition: 2},
{memberPositions: []int{1, 3}, canonicalPosition: 1},
}}
var canonicalNames []string
result, err := ApplyPlan(plan, records, ApplicationPolicy[syntheticRecord]{
CloneValue: cloneSyntheticRecord,
ConsolidateGroup: func(members []syntheticRecord, canonical syntheticRecord) (syntheticRecord, error) {
canonicalNames = append(canonicalNames, canonical.Name)
canonical.Notes = []string{members[0].Name, members[1].Name}
return canonical, nil
},
})
if err != nil {
t.Fatalf("ApplyPlan() error = %v", err)
}
got := result.Records()
if names := recordNames(got); !reflect.DeepEqual(names, []string{"bravo", "charlie"}) {
t.Fatalf("record names = %v, want [bravo charlie]", names)
}
if !reflect.DeepEqual(canonicalNames, []string{"charlie", "bravo"}) {
t.Fatalf("canonical values = %v, want [charlie bravo]", canonicalNames)
}
if indexes := got[0].OriginalInputIndexes(); !reflect.DeepEqual(indexes, []int{0, 1, 2, 3}) {
t.Fatalf("first provenance indexes = %v, want [0 1 2 3]", indexes)
}
if indexes := got[1].OriginalInputIndexes(); !reflect.DeepEqual(indexes, []int{2, 4}) {
t.Fatalf("second provenance indexes = %v, want [2 4]", indexes)
}
if got[0].EarliestInputPosition() != 0 || got[1].EarliestInputPosition() != 2 {
t.Fatalf("earliest positions = [%d %d], want [0 2]", got[0].EarliestInputPosition(), got[1].EarliestInputPosition())
}
events := result.AppliedGroups()
if len(events) != 2 || len(result.RejectedGroups()) != 0 {
t.Fatalf("event counts = applied %d rejected %d, want 2 and 0", len(events), len(result.RejectedGroups()))
}
first := events[0].Provenance()
if !reflect.DeepEqual(first.MemberPositions(), []int{0, 2}) || first.CanonicalPosition() != 2 || !reflect.DeepEqual(first.OriginalInputIndexes(), []int{2, 4}) || first.EarliestInputPosition() != 2 {
t.Fatalf("first applied provenance = members %v canonical %d indexes %v earliest %d", first.MemberPositions(), first.CanonicalPosition(), first.OriginalInputIndexes(), first.EarliestInputPosition())
}
}
func TestApplyPlanWithoutGroupsReturnsOwnedRecordsInProvenanceOrder(t *testing.T) {
records := syntheticRecords(t,
recordFixture{"alpha", []int{2}, 2},
recordFixture{"bravo", []int{0}, 0},
recordFixture{"charlie", []int{1}, 1},
)
result, err := ApplyPlan(Plan{}, records, syntheticPolicy())
if err != nil {
t.Fatalf("ApplyPlan() error = %v", err)
}
if names := recordNames(result.Records()); !reflect.DeepEqual(names, []string{"bravo", "charlie", "alpha"}) {
t.Fatalf("record names = %v, want [bravo charlie alpha]", names)
}
if result.AppliedGroups() != nil || result.RejectedGroups() != nil {
t.Fatalf("events = applied %#v rejected %#v, want nil", result.AppliedGroups(), result.RejectedGroups())
}
value := result.Records()[0].Value()
value.Notes[0] = "changed"
if records[1].Value().Notes[0] != "bravo" || result.Records()[0].Value().Notes[0] != "bravo" {
t.Fatal("no-group result shares mutable value state")
}
}
func TestApplyPlanPreservesUngroupedRecordsAndOwnsResults(t *testing.T) {
records := syntheticRecords(t,
recordFixture{"alpha", []int{4}, 4},
recordFixture{"bravo", []int{1}, 1},
recordFixture{"charlie", []int{2}, 2},
)
result, err := ApplyPlan(Plan{groups: []PlanGroup{{memberPositions: []int{0, 2}, canonicalPosition: 0}}}, records, syntheticPolicy())
if err != nil {
t.Fatalf("ApplyPlan() error = %v", err)
}
if names := recordNames(result.Records()); !reflect.DeepEqual(names, []string{"bravo", "alpha"}) {
t.Fatalf("record names = %v, want ungrouped bravo then consolidated alpha", names)
}
firstRead := result.Records()
firstValue := firstRead[0].Value()
firstValue.Notes[0] = "changed"
firstRead[0].originalInputIndexes[0] = 99
if again := result.Records(); again[0].Value().Notes[0] != "bravo" || !reflect.DeepEqual(again[0].OriginalInputIndexes(), []int{1}) {
t.Fatalf("result retained accessor mutations: %#v", again[0])
}
if original := records[1].Value(); original.Notes[0] != "bravo" {
t.Fatalf("input record was mutated: %#v", original)
}
}
func TestApplyPlanGuardRejectionPreservesEveryMemberOnce(t *testing.T) {
records := syntheticRecords(t,
recordFixture{"alpha", []int{3}, 3},
recordFixture{"bravo", []int{1}, 1},
recordFixture{"charlie", []int{2}, 2},
)
consolidations := 0
result, err := ApplyPlan(Plan{groups: []PlanGroup{{memberPositions: []int{0, 2}, canonicalPosition: 2}}}, records, ApplicationPolicy[syntheticRecord]{
CloneValue: cloneSyntheticRecord,
RejectGroup: func(members []syntheticRecord, canonical syntheticRecord) RejectionCategory {
members[0].Notes[0] = "guard mutation"
canonical.Notes[0] = "canonical mutation"
return "typed_constraint"
},
ConsolidateGroup: func([]syntheticRecord, syntheticRecord) (syntheticRecord, error) {
consolidations++
return syntheticRecord{}, nil
},
})
if err != nil {
t.Fatalf("ApplyPlan() error = %v", err)
}
if consolidations != 0 {
t.Fatalf("consolidation calls = %d, want 0", consolidations)
}
if names := recordNames(result.Records()); !reflect.DeepEqual(names, []string{"bravo", "charlie", "alpha"}) {
t.Fatalf("preserved record names = %v, want [bravo charlie alpha]", names)
}
for index, record := range records {
if got := record.Value().Notes[0]; got != record.Value().Name {
t.Fatalf("input record %d note = %q after guard, want original", index, got)
}
}
rejected := result.RejectedGroups()
if len(rejected) != 1 || rejected[0].Category() != "typed_constraint" {
t.Fatalf("rejected events = %#v, want typed_constraint", rejected)
}
provenance := rejected[0].Provenance()
if !reflect.DeepEqual(provenance.MemberPositions(), []int{0, 2}) || !reflect.DeepEqual(provenance.OriginalInputIndexes(), []int{2, 3}) || provenance.EarliestInputPosition() != 2 {
t.Fatalf("rejected provenance = members %v indexes %v earliest %d", provenance.MemberPositions(), provenance.OriginalInputIndexes(), provenance.EarliestInputPosition())
}
}
func TestApplyPlanSuppliesFreshPolicyValuesAndDoesNotMutateInputs(t *testing.T) {
records := syntheticRecords(t,
recordFixture{"alpha", []int{0}, 0},
recordFixture{"bravo", []int{1}, 1},
)
result, err := ApplyPlan(Plan{groups: []PlanGroup{{memberPositions: []int{0, 1}, canonicalPosition: 1}}}, records, ApplicationPolicy[syntheticRecord]{
CloneValue: cloneSyntheticRecord,
RejectGroup: func(members []syntheticRecord, canonical syntheticRecord) RejectionCategory {
members[0].Name = "guard mutation"
canonical.Name = "guard canonical mutation"
return ""
},
ConsolidateGroup: func(members []syntheticRecord, canonical syntheticRecord) (syntheticRecord, error) {
if members[0].Name != "alpha" || canonical.Name != "bravo" {
t.Fatalf("consolidation observed guard mutations: members %#v canonical %#v", members, canonical)
}
members[0].Notes[0] = "consolidator mutation"
canonical.Notes = []string{"result"}
return canonical, nil
},
})
if err != nil {
t.Fatalf("ApplyPlan() error = %v", err)
}
if got := result.Records()[0].Value(); got.Name != "bravo" || !reflect.DeepEqual(got.Notes, []string{"result"}) {
t.Fatalf("consolidated value = %#v", got)
}
if records[0].Value().Notes[0] != "alpha" || records[1].Value().Notes[0] != "bravo" {
t.Fatalf("input records changed: %#v %#v", records[0].Value(), records[1].Value())
}
}
func TestApplyPlanRejectsMalformedPlans(t *testing.T) {
records := syntheticRecords(t,
recordFixture{"alpha", nil, 0},
recordFixture{"bravo", nil, 1},
recordFixture{"charlie", nil, 2},
)
tests := []struct {
name string
plan Plan
want string
}{
{name: "member out of range", plan: Plan{groups: []PlanGroup{{memberPositions: []int{0, 3}, canonicalPosition: 0}}}, want: "outside record range"},
{name: "canonical out of range", plan: Plan{groups: []PlanGroup{{memberPositions: []int{0, 1}, canonicalPosition: 3}}}, want: "canonical position"},
{name: "canonical not a member", plan: Plan{groups: []PlanGroup{{memberPositions: []int{0, 1}, canonicalPosition: 2}}}, want: "is not a member"},
{name: "duplicate member", plan: Plan{groups: []PlanGroup{{memberPositions: []int{0, 0}, canonicalPosition: 0}}}, want: "ascending and unique"},
{name: "overlapping groups", plan: Plan{groups: []PlanGroup{{memberPositions: []int{0, 1}, canonicalPosition: 0}, {memberPositions: []int{1, 2}, canonicalPosition: 1}}}, want: "multiple groups"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := ApplyPlan(test.plan, records, syntheticPolicy())
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("ApplyPlan() error = %v, want containing %q", err, test.want)
}
})
}
}
func TestApplyPlanReturnsConsolidationFailures(t *testing.T) {
records := syntheticRecords(t,
recordFixture{"alpha", nil, 0},
recordFixture{"bravo", nil, 1},
)
want := errors.New("cannot consolidate")
result, err := ApplyPlan(Plan{groups: []PlanGroup{{memberPositions: []int{0, 1}, canonicalPosition: 0}}}, records, ApplicationPolicy[syntheticRecord]{
CloneValue: cloneSyntheticRecord,
ConsolidateGroup: func([]syntheticRecord, syntheticRecord) (syntheticRecord, error) {
return syntheticRecord{}, want
},
})
if !errors.Is(err, want) || !strings.Contains(err.Error(), "position 0") {
t.Fatalf("ApplyPlan() error = %v, want contextual wrapped failure", err)
}
if result.Records() != nil || result.AppliedGroups() != nil || result.RejectedGroups() != nil {
t.Fatalf("ApplyPlan() partial result = %#v, want zero result", result)
}
}
func TestApplyPlanPreservesNilAndEmptyRecordCollections(t *testing.T) {
policy := syntheticPolicy()
nilResult, err := ApplyPlan(Plan{}, []Record[syntheticRecord](nil), policy)
if err != nil {
t.Fatalf("ApplyPlan(nil) error = %v", err)
}
emptyResult, err := ApplyPlan(Plan{}, []Record[syntheticRecord]{}, policy)
if err != nil {
t.Fatalf("ApplyPlan(empty) error = %v", err)
}
if nilResult.Records() != nil {
t.Fatal("nil records became non-nil")
}
if got := emptyResult.Records(); got == nil || len(got) != 0 {
t.Fatalf("empty records = %#v, want non-nil empty", got)
}
}
func TestApplyPlanValidatesPolicyAndRecordState(t *testing.T) {
valid := syntheticPolicy()
if _, err := ApplyPlan(Plan{}, []Record[syntheticRecord]{}, ApplicationPolicy[syntheticRecord]{ConsolidateGroup: valid.ConsolidateGroup}); err == nil {
t.Fatal("ApplyPlan() accepted nil clone function")
}
if _, err := ApplyPlan(Plan{}, []Record[syntheticRecord]{}, ApplicationPolicy[syntheticRecord]{CloneValue: cloneSyntheticRecord}); err == nil {
t.Fatal("ApplyPlan() accepted nil consolidate function")
}
if _, err := ApplyPlan(Plan{}, []Record[syntheticRecord]{{}}, valid); err == nil || !strings.Contains(err.Error(), "record 0") {
t.Fatalf("ApplyPlan() invalid record error = %v", err)
}
records := syntheticRecords(t,
recordFixture{"alpha", nil, 0},
recordFixture{"bravo", nil, 1},
)
valid.RejectGroup = func([]syntheticRecord, syntheticRecord) RejectionCategory { return " " }
if _, err := ApplyPlan(Plan{groups: []PlanGroup{{memberPositions: []int{0, 1}, canonicalPosition: 0}}}, records, valid); err == nil || !strings.Contains(err.Error(), "category") {
t.Fatalf("ApplyPlan() blank category error = %v", err)
}
}
type recordFixture struct {
name string
indexes []int
earliest int
}
func syntheticRecords(t *testing.T, fixtures ...recordFixture) []Record[syntheticRecord] {
t.Helper()
records := make([]Record[syntheticRecord], len(fixtures))
for index, fixture := range fixtures {
record, err := NewRecord(syntheticRecord{Name: fixture.name, Notes: []string{fixture.name}}, fixture.indexes, fixture.earliest, cloneSyntheticRecord)
if err != nil {
t.Fatalf("NewRecord(%d) error = %v", index, err)
}
records[index] = record
}
return records
}
func syntheticPolicy() ApplicationPolicy[syntheticRecord] {
return ApplicationPolicy[syntheticRecord]{
CloneValue: cloneSyntheticRecord,
ConsolidateGroup: func(_ []syntheticRecord, canonical syntheticRecord) (syntheticRecord, error) {
return canonical, nil
},
}
}
func recordNames(records []Record[syntheticRecord]) []string {
names := make([]string, len(records))
for index, record := range records {
names[index] = record.Value().Name
}
return names
}

View File

@@ -0,0 +1,111 @@
package semanticreconcile
import (
"errors"
"fmt"
"io/fs"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
)
const (
PromptID = "generic.semantic_reconciliation"
PromptVersion = "v1"
promptRoot = "assets/prompts"
)
var promptFiles = []promptfs.ModulePromptFile{
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
{Name: "system.md", Path: "prompts/system.md"},
{Name: "protocol.md", Path: "prompts/protocol.md"},
{Name: "instructions.md", Path: "prompts/instructions.md"},
{Name: "candidates.md", Path: "prompts/candidates.md"},
{Name: "transcript-windows.md", Path: "prompts/transcript-windows.md"},
}
// RegisterAssets registers the generic reconciliation prompt and response
// schema as one production-owned asset set.
func RegisterAssets(registry *llm.AssetRegistry) error {
if registry == nil {
return fmt.Errorf("semantic reconciliation asset registry must not be nil")
}
if err := ensureAssetsAbsent(registry); err != nil {
return err
}
assets, err := assetFS()
if err != nil {
return err
}
prompts, err := promptfs.ModulePromptFS(PromptID, assets, append([]promptfs.ModulePromptFile(nil), promptFiles...))
if err != nil {
return fmt.Errorf("prepare semantic reconciliation prompt assets: %w", err)
}
if err := registry.RegisterPromptFS(prompts, promptRoot); err != nil {
return fmt.Errorf("register semantic reconciliation prompt assets: %w", err)
}
if err := registry.RegisterSchemaFS(assets, "schemas"); err != nil {
return fmt.Errorf("register semantic reconciliation schema assets: %w", err)
}
return nil
}
// PromptHash returns the deterministic identity of the complete generic prompt.
func PromptHash() (string, error) {
assets, err := assetFS()
if err != nil {
return "", err
}
parts := make([]llm.AssetHashPart, 0, len(promptFiles))
for _, file := range promptFiles {
parts = append(parts, llm.AssetHashPart{FS: assets, Path: file.Path})
}
return llm.HashAssets(parts)
}
// SchemaHash returns the deterministic identity of the response schema.
func SchemaHash() (string, error) {
schema, err := LoadResponseSchema()
if err != nil {
return "", err
}
return schema.SHA256, nil
}
// SharedPromptFiles returns fresh descriptors for the mandatory protocol and
// variable-input presentation assets that domain prompts may reuse.
func SharedPromptFiles() ([]promptfs.SharedPromptFile, error) {
assets, err := assetFS()
if err != nil {
return nil, err
}
return []promptfs.SharedPromptFile{
{Name: "protocol.md", FS: assets, Path: "prompts/protocol.md"},
{Name: "candidates.md", FS: assets, Path: "prompts/candidates.md"},
{Name: "transcript-windows.md", FS: assets, Path: "prompts/transcript-windows.md"},
}, nil
}
func ensureAssetsAbsent(registry *llm.AssetRegistry) error {
prompts, err := registry.PromptFS()
if err != nil {
return fmt.Errorf("inspect registered prompt assets: %w", err)
}
if _, err := fs.Stat(prompts, PromptID+"/prompt.yaml"); err == nil {
return fmt.Errorf("semantic reconciliation prompt assets already registered")
} else if !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("inspect semantic reconciliation prompt assets: %w", err)
}
schemas, err := registry.SchemaFS()
if err != nil {
return fmt.Errorf("inspect registered schema assets: %w", err)
}
if _, err := fs.Stat(schemas, "semantic_reconciliation_llm.v1.json"); err == nil {
return fmt.Errorf("semantic reconciliation schema assets already registered")
} else if !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("inspect semantic reconciliation schema assets: %w", err)
}
return nil
}

View File

@@ -0,0 +1,118 @@
package semanticreconcile
import (
"context"
"io/fs"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/promptkit"
)
func TestRegisterAssetsPreparesGenericPromptOffline(t *testing.T) {
registry := llm.NewAssetRegistry()
if err := RegisterAssets(registry); err != nil {
t.Fatalf("RegisterAssets() error = %v, want nil", err)
}
options, err := registry.PromptKitOptions()
if err != nil {
t.Fatalf("PromptKitOptions() error = %v, want nil", err)
}
options = append(options, promptkit.WithProfiles(promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
ID: "semantic-reconciliation-test", Endpoint: "http://127.0.0.1:1/v1", Model: "offline-test-model",
})))
engine, err := promptkit.NewEngine(promptkit.Config{Timeout: time.Second}, options...)
if err != nil {
t.Fatalf("NewEngine() error = %v, want nil", err)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "semantic-reconciliation-test",
Inputs: map[string]promptkit.ArtifactRef{
"candidates": promptkit.Inline(`{"candidates":[{"candidate_id":1,"label":"Mira"},{"candidate_id":2,"label":"Captain Mira"}]}`),
"transcript": promptkit.Inline(`{"windows":[{"units":[{"unit_id":7,"text":"Mira arrived."}]}]}`),
},
})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
if prepared.PromptID != PromptID || prepared.PromptVersion != PromptVersion {
t.Fatalf("prepared prompt identity = %q %q, want %q %q", prepared.PromptID, prepared.PromptVersion, PromptID, PromptVersion)
}
if prepared.SelectedProfileID != "semantic-reconciliation-test" {
t.Fatalf("selected profile = %q, want explicit test profile", prepared.SelectedProfileID)
}
if contract := prepared.OutputContract; contract.SchemaPath != "semantic_reconciliation_llm.v1.json" || contract.RepairAttempts != 0 {
t.Fatalf("output contract = %#v, want generic schema without repair", contract)
}
if len(prepared.Messages) != 5 || prepared.Messages[0].Role != "system" {
t.Fatalf("prepared messages = %#v, want five ordered messages beginning with system", prepared.Messages)
}
if cache := prepared.Messages[2].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral {
t.Fatalf("semantic policy cache control = %#v, want ephemeral", cache)
}
for _, index := range []int{0, 1, 3, 4} {
if prepared.Messages[index].CacheControl != nil {
t.Fatalf("message %d cache control = %#v, want nil", index, prepared.Messages[index].CacheControl)
}
}
protocol := prepared.Messages[1].Content
for _, requirement := range []string{"positive integer", "Return IDs only", "do not copy candidate names", "source ranges"} {
if !strings.Contains(protocol, requirement) {
t.Fatalf("protocol message = %q, want requirement %q", protocol, requirement)
}
}
if !strings.Contains(prepared.Messages[3].Content, `"candidate_id":1`) || strings.Contains(prepared.Messages[3].Content, `"windows"`) {
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"`) {
t.Fatalf("transcript message = %q, want only transcript windows", prepared.Messages[4].Content)
}
}
func TestAssetHashesAreDeterministicAndComplete(t *testing.T) {
firstPrompt, err := PromptHash()
if err != nil {
t.Fatal(err)
}
secondPrompt, err := PromptHash()
if err != nil {
t.Fatal(err)
}
schemaHash, err := SchemaHash()
if err != nil {
t.Fatal(err)
}
if firstPrompt == "" || firstPrompt != secondPrompt || schemaHash == "" || firstPrompt == schemaHash {
t.Fatalf("asset hashes = prompt %q/%q schema %q, want stable distinct hashes", firstPrompt, secondPrompt, schemaHash)
}
}
func TestSharedPromptFilesExposeOnlyReusableCoreAssets(t *testing.T) {
first, err := SharedPromptFiles()
if err != nil {
t.Fatal(err)
}
second, err := SharedPromptFiles()
if err != nil {
t.Fatal(err)
}
wantNames := []string{"protocol.md", "candidates.md", "transcript-windows.md"}
gotNames := make([]string, len(first))
for index, file := range first {
gotNames[index] = file.Name
if content, err := fs.ReadFile(file.FS, file.Path); err != nil || len(content) == 0 {
t.Fatalf("shared file %q = %q, %v; want readable content", file.Name, content, err)
}
}
if !reflect.DeepEqual(gotNames, wantNames) {
t.Fatalf("shared files = %#v, want narrow allowlist %#v", gotNames, wantNames)
}
first[0].Name = "changed.md"
if second[0].Name != "protocol.md" {
t.Fatalf("SharedPromptFiles() reused mutable descriptors: %#v", second)
}
}

View File

@@ -0,0 +1,3 @@
// Package semanticreconcile prepares, executes, and validates bounded
// domain-neutral semantic reconciliation requests.
package semanticreconcile

View File

@@ -0,0 +1,210 @@
package semanticreconcile
import (
"context"
"errors"
"fmt"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
)
// PromptSpec identifies the exact prompt selected by a reconciliation owner.
type PromptSpec struct {
ID string
Version string
SHA256 string
}
// Validate rejects incomplete prompt identity and non-canonical digests.
func (spec PromptSpec) Validate() error {
if strings.TrimSpace(spec.ID) == "" {
return fmt.Errorf("semantic reconciliation prompt ID must not be empty")
}
if strings.TrimSpace(spec.Version) == "" {
return fmt.Errorf("semantic reconciliation prompt version must not be empty")
}
if err := validateSHA256(spec.SHA256); err != nil {
return fmt.Errorf("semantic reconciliation prompt digest: %w", err)
}
return nil
}
// DefaultPromptSpec returns the identity of the core-owned generic prompt.
func DefaultPromptSpec() (PromptSpec, error) {
digest, err := PromptHash()
if err != nil {
return PromptSpec{}, err
}
return PromptSpec{ID: PromptID, Version: PromptVersion, SHA256: digest}, nil
}
// Request contains one typed owner's source-backed reconciliation input.
type Request struct {
StageName string
Source *source.SourceDocument
Candidates []Candidate
ProfileID string
SessionID string
}
// ResultDisposition classifies a provider-neutral reconciliation outcome.
type ResultDisposition uint8
const (
Complete ResultDisposition = iota + 1
RetryableInvalidStructuredOutput
RetryableDiscardedProposalGroups
SkippedInsufficientCandidates
SkippedLimitExceeded
)
// Result owns the safe plan and neutral diagnostics from one call.
type Result struct {
disposition ResultDisposition
plan Plan
issues []Issue
discardedGroupCount int
candidateMappings []CandidateMapping
}
// Disposition returns the classified outcome.
func (result Result) Disposition() ResultDisposition { return result.disposition }
// Plan returns an independently owned safe plan.
func (result Result) Plan() Plan { return result.planCopy() }
// Issues returns an owned copy of stable proposal issues.
func (result Result) Issues() []Issue { return append([]Issue(nil), result.issues...) }
// DiscardedGroupCount returns the number of excluded proposal groups.
func (result Result) DiscardedGroupCount() int { return result.discardedGroupCount }
// CandidateMappings returns the request-local handle mapping used for this call.
func (result Result) CandidateMappings() []CandidateMapping {
return append([]CandidateMapping(nil), result.candidateMappings...)
}
func (result Result) planCopy() Plan {
return Plan{groups: result.plan.Groups()}
}
// Engine prepares bounded material, performs one structured completion, and
// classifies the deterministic assessment without applying it to typed values.
type Engine struct {
client contracts.StructuredLLMClient
prompt PromptSpec
schema llm.ResponseSchema
limits Limits
}
// NewEngine constructs a reconciliation engine using the core response schema.
func NewEngine(client contracts.StructuredLLMClient, prompt PromptSpec, limits Limits) (*Engine, error) {
if client == nil {
return nil, fmt.Errorf("construct semantic reconciliation engine: LLM client must not be nil")
}
if err := prompt.Validate(); err != nil {
return nil, fmt.Errorf("construct semantic reconciliation engine: %w", err)
}
if err := limits.Validate(); err != nil {
return nil, fmt.Errorf("construct semantic reconciliation engine: %w", err)
}
schema, err := LoadResponseSchema()
if err != nil {
return nil, fmt.Errorf("construct semantic reconciliation engine: load response schema: %w", err)
}
return newEngine(client, prompt, schema, limits), nil
}
func newEngine(client contracts.StructuredLLMClient, prompt PromptSpec, schema llm.ResponseSchema, limits Limits) *Engine {
return &Engine{client: client, prompt: prompt, schema: schema, limits: limits}
}
// Reconcile prepares and assesses one request. Retryable semantic outcomes are
// returned as results; provider and transport failures remain errors.
func (engine *Engine) Reconcile(ctx context.Context, request Request) (Result, error) {
if err := engine.validate(); err != nil {
return Result{}, err
}
if ctx == nil {
return Result{}, fmt.Errorf("semantic reconciliation %q: context must not be nil", request.StageName)
}
if strings.TrimSpace(request.StageName) == "" {
return Result{}, fmt.Errorf("semantic reconciliation stage name must not be empty")
}
if request.Source == nil {
return Result{}, fmt.Errorf("semantic reconciliation %q: source document must not be nil", request.StageName)
}
if err := ctx.Err(); err != nil {
return Result{}, fmt.Errorf("semantic reconciliation %q: context error before preparation: %w", request.StageName, err)
}
preparation, err := Prepare(request.Source, request.Candidates, engine.limits)
if err != nil {
return Result{}, fmt.Errorf("semantic reconciliation %q: prepare materials: %w", request.StageName, err)
}
result := Result{candidateMappings: preparation.CandidateMappings()}
switch preparation.Disposition() {
case InsufficientCandidates:
result.disposition = SkippedInsufficientCandidates
return result, nil
case LimitExceeded:
result.disposition = SkippedLimitExceeded
return result, nil
case Ready:
default:
return Result{}, fmt.Errorf("semantic reconciliation %q: unknown preparation disposition %d", request.StageName, preparation.Disposition())
}
if err := ctx.Err(); err != nil {
return Result{}, fmt.Errorf("semantic reconciliation %q: context error before completion: %w", request.StageName, err)
}
var response ProposalResponse
_, err = engine.client.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: request.StageName,
PromptID: engine.prompt.ID,
PromptVersion: engine.prompt.Version,
ProfileID: request.ProfileID,
SessionID: request.SessionID,
Inputs: preparation.Materials(),
}, &response)
if err != nil {
if errors.Is(err, contracts.ErrInvalidStructuredOutput) {
result.disposition = RetryableInvalidStructuredOutput
return result, nil
}
return Result{}, fmt.Errorf("semantic reconciliation %q: complete structured output: %w", request.StageName, err)
}
assessment := preparation.Assess(response)
result.plan = assessment.Plan()
result.issues = assessment.Issues()
result.discardedGroupCount = assessment.DiscardedGroupCount()
if result.discardedGroupCount > 0 {
result.disposition = RetryableDiscardedProposalGroups
} else {
result.disposition = Complete
}
return result, nil
}
func (engine *Engine) validate() error {
if engine == nil {
return fmt.Errorf("semantic reconciliation engine must not be nil")
}
if engine.client == nil {
return fmt.Errorf("semantic reconciliation engine: LLM client must not be nil")
}
if err := engine.prompt.Validate(); err != nil {
return fmt.Errorf("semantic reconciliation engine: invalid construction state: %w", err)
}
if err := engine.limits.Validate(); err != nil {
return fmt.Errorf("semantic reconciliation engine: invalid construction state: %w", err)
}
if err := validateResponseSchemaIdentity(engine.schema); err != nil {
return fmt.Errorf("semantic reconciliation engine: invalid construction state: %w", err)
}
return nil
}

View File

@@ -0,0 +1,304 @@
package semanticreconcile
import (
"context"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
)
func TestNewEngineValidatesConstruction(t *testing.T) {
prompt := testPromptSpec("a")
tests := []struct {
name string
client contracts.StructuredLLMClient
prompt PromptSpec
limits Limits
want string
}{
{name: "nil client", prompt: prompt, limits: DefaultLimits(), want: "client"},
{name: "empty prompt ID", client: &recordingReconciliationClient{}, prompt: PromptSpec{Version: "v1", SHA256: testDigest("a")}, limits: DefaultLimits(), want: "prompt ID"},
{name: "empty prompt version", client: &recordingReconciliationClient{}, prompt: PromptSpec{ID: "prompt", SHA256: testDigest("a")}, limits: DefaultLimits(), want: "prompt version"},
{name: "missing digest", client: &recordingReconciliationClient{}, prompt: PromptSpec{ID: "prompt", Version: "v1"}, limits: DefaultLimits(), want: "digest"},
{name: "wrong digest algorithm", client: &recordingReconciliationClient{}, prompt: PromptSpec{ID: "prompt", Version: "v1", SHA256: "md5:" + strings.Repeat("a", 32)}, limits: DefaultLimits(), want: "sha256:"},
{name: "non canonical digest", client: &recordingReconciliationClient{}, prompt: PromptSpec{ID: "prompt", Version: "v1", SHA256: "sha256:" + strings.Repeat("A", 64)}, limits: DefaultLimits(), want: "lowercase"},
{name: "invalid limits", client: &recordingReconciliationClient{}, prompt: prompt, limits: Limits{}, want: "limits"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := NewEngine(test.client, test.prompt, test.limits); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("NewEngine() error = %v, want %q", err, test.want)
}
})
}
defaultPrompt, err := DefaultPromptSpec()
if err != nil {
t.Fatal(err)
}
if defaultPrompt.ID != PromptID || defaultPrompt.Version != PromptVersion || defaultPrompt.SHA256 == "" {
t.Fatalf("DefaultPromptSpec() = %#v, want complete generic prompt identity", defaultPrompt)
}
}
func TestEnginePropagatesRequestAndAssessesResponse(t *testing.T) {
client := &recordingReconciliationClient{responses: []ProposalResponse{{DuplicateGroups: []DuplicateGroup{{
CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2,
}}}}}
engine := newTestEngine(t, client, DefaultLimits())
request := readyEngineRequest()
request.ProfileID = " profile-as-resolved "
request.SessionID = " session-as-supplied "
result, err := engine.Reconcile(context.Background(), request)
if err != nil {
t.Fatalf("Reconcile() error = %v, want nil", err)
}
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())
}
groups := result.Plan().Groups()
if len(groups) != 1 || !reflect.DeepEqual(groups[0].MemberPositions(), []int{0, 1}) || groups[0].CanonicalPosition() != 1 {
t.Fatalf("safe plan = %#v", groups)
}
if len(client.requests) != 1 {
t.Fatalf("completion calls = %d, want exactly one", len(client.requests))
}
got := client.requests[0]
if got.StageName != request.StageName || got.PromptID != engine.prompt.ID || got.PromptVersion != engine.prompt.Version || got.ProfileID != request.ProfileID || got.SessionID != request.SessionID {
t.Fatalf("structured request = %#v, want exact routing values", got)
}
if len(got.Inputs) != 2 || got.Inputs["candidates"].Name != "candidates" || got.Inputs["transcript"].Name != "transcript" || len(got.Vars) != 0 {
t.Fatalf("structured request inputs = %#v vars = %#v, want only candidate and transcript materials", got.Inputs, got.Vars)
}
}
func TestEngineClassifiesSemanticAndTransportOutcomes(t *testing.T) {
transportErr := errors.New("provider unavailable")
tests := []struct {
name string
response ProposalResponse
completion error
want ResultDisposition
wantDiscard int
wantIssues bool
wantError error
}{
{name: "empty groups complete", response: ProposalResponse{DuplicateGroups: []DuplicateGroup{}}, want: Complete},
{name: "discarded proposal retryable", response: ProposalResponse{DuplicateGroups: []DuplicateGroup{{CandidateIDs: []int{1, 99}, CanonicalCandidateID: 1}}}, want: RetryableDiscardedProposalGroups, wantDiscard: 1, wantIssues: true},
{name: "invalid structured output retryable", completion: fmt.Errorf("decode response: %w", contracts.ErrInvalidStructuredOutput), want: RetryableInvalidStructuredOutput},
{name: "transport failure", completion: transportErr, wantError: transportErr},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
client := &recordingReconciliationClient{responses: []ProposalResponse{test.response}, errors: []error{test.completion}}
result, err := newTestEngine(t, client, DefaultLimits()).Reconcile(context.Background(), readyEngineRequest())
if test.wantError != nil {
if !errors.Is(err, test.wantError) || !strings.Contains(err.Error(), readyEngineRequest().StageName) {
t.Fatalf("Reconcile() error = %v, want contextual %v", err, test.wantError)
}
return
}
if err != nil {
t.Fatalf("Reconcile() error = %v, want nil", err)
}
if result.Disposition() != test.want || result.DiscardedGroupCount() != test.wantDiscard || (len(result.Issues()) > 0) != test.wantIssues {
t.Fatalf("result = disposition %v discarded %d issues %#v", result.Disposition(), result.DiscardedGroupCount(), result.Issues())
}
if len(client.requests) != 1 {
t.Fatalf("completion calls = %d, want one", len(client.requests))
}
})
}
}
func TestEngineSkipsDeterministicOutcomesWithoutCompletion(t *testing.T) {
tests := []struct {
name string
request Request
limits Limits
want ResultDisposition
mappingLen int
}{
{name: "insufficient candidates", request: engineRequestWithCandidateCount(1), limits: DefaultLimits(), want: SkippedInsufficientCandidates, mappingLen: 1},
{name: "candidate limit", request: engineRequestWithCandidateCount(2), limits: Limits{ContextRadius: 0, MaximumCandidates: 1, MaximumMaterialBytes: 10000}, want: SkippedLimitExceeded, mappingLen: 2},
{name: "material limit", request: engineRequestWithCandidateCount(2), limits: Limits{ContextRadius: 0, MaximumCandidates: 2, MaximumMaterialBytes: 1}, want: SkippedLimitExceeded, mappingLen: 2},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
client := &recordingReconciliationClient{}
result, err := newTestEngine(t, client, test.limits).Reconcile(context.Background(), test.request)
if err != nil {
t.Fatalf("Reconcile() error = %v, want nil", err)
}
if result.Disposition() != test.want || len(result.CandidateMappings()) != test.mappingLen {
t.Fatalf("result disposition = %v mappings = %#v", result.Disposition(), result.CandidateMappings())
}
if len(client.requests) != 0 {
t.Fatalf("completion calls = %d, want zero", len(client.requests))
}
})
}
}
func TestEngineRejectsInvalidInvocationAndHonorsCancellation(t *testing.T) {
request := readyEngineRequest()
client := &recordingReconciliationClient{}
engine := newTestEngine(t, client, DefaultLimits())
var nilEngine *Engine
if _, err := nilEngine.Reconcile(context.Background(), request); err == nil || !strings.Contains(err.Error(), "engine must not be nil") {
t.Fatalf("nil engine error = %v", err)
}
if _, err := (&Engine{}).Reconcile(context.Background(), request); err == nil || !strings.Contains(err.Error(), "client") {
t.Fatalf("zero engine error = %v", err)
}
invalid := newEngine(&recordingReconciliationClient{}, testPromptSpec("a"), llm.ResponseSchema{}, DefaultLimits())
if _, err := invalid.Reconcile(context.Background(), request); err == nil || !strings.Contains(err.Error(), "invalid construction state") {
t.Fatalf("invalid construction error = %v", err)
}
if _, err := engine.Reconcile(nil, request); err == nil || !strings.Contains(err.Error(), "context") {
t.Fatalf("nil context error = %v", err)
}
withoutStage := request
withoutStage.StageName = " "
if _, err := engine.Reconcile(context.Background(), withoutStage); err == nil || !strings.Contains(err.Error(), "stage name") {
t.Fatalf("empty stage error = %v", err)
}
withoutSource := request
withoutSource.Source = nil
if _, err := engine.Reconcile(context.Background(), withoutSource); err == nil || !strings.Contains(err.Error(), "source document") {
t.Fatalf("nil source error = %v", err)
}
canceled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := engine.Reconcile(canceled, request); !errors.Is(err, context.Canceled) || !strings.Contains(err.Error(), "before preparation") {
t.Fatalf("preparation cancellation error = %v", err)
}
if _, err := engine.Reconcile(&cancelBeforeCompletionContext{}, request); !errors.Is(err, context.Canceled) || !strings.Contains(err.Error(), "before completion") {
t.Fatalf("completion cancellation error = %v", err)
}
if len(client.requests) != 0 {
t.Fatalf("completion calls = %d, want zero for invalid and canceled invocations", len(client.requests))
}
}
func TestEngineCallsAreIndependentAndResultsAreOwned(t *testing.T) {
client := &recordingReconciliationClient{responses: []ProposalResponse{
{DuplicateGroups: []DuplicateGroup{
{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 1},
{CandidateIDs: []int{2, 99}, CanonicalCandidateID: 2},
}},
{DuplicateGroups: []DuplicateGroup{}},
}}
engine := newTestEngine(t, client, DefaultLimits())
first, err := engine.Reconcile(context.Background(), readyEngineRequest())
if err != nil {
t.Fatal(err)
}
first.Plan().Groups()[0].memberPositions[0] = 99
firstIssues := first.Issues()
firstIssues[0].Category = "changed"
firstMappings := first.CandidateMappings()
firstMappings[0].CandidatePosition = 99
if first.Plan().Groups()[0].MemberPositions()[0] != 0 || first.Issues()[0].Category == "changed" || first.CandidateMappings()[0].CandidatePosition != 0 {
t.Fatal("result accessors exposed retained state")
}
second, err := engine.Reconcile(context.Background(), readyEngineRequest())
if err != nil {
t.Fatal(err)
}
if second.Disposition() != Complete || len(second.Plan().Groups()) != 0 || len(second.Issues()) != 0 || second.DiscardedGroupCount() != 0 || len(second.CandidateMappings()) != 2 {
t.Fatalf("second result retained prior call state: disposition %v plan %#v issues %#v discarded %d mappings %#v", second.Disposition(), second.Plan().Groups(), second.Issues(), second.DiscardedGroupCount(), second.CandidateMappings())
}
}
type recordingReconciliationClient struct {
requests []contracts.StructuredCompletionRequest
responses []ProposalResponse
errors []error
}
func (client *recordingReconciliationClient) CompleteStructured(_ context.Context, request contracts.StructuredCompletionRequest, output any) (contracts.StructuredCompletionResponse, error) {
request.Inputs = request.Inputs.Clone()
client.requests = append(client.requests, request)
index := len(client.requests) - 1
if index < len(client.errors) && client.errors[index] != nil {
return contracts.StructuredCompletionResponse{}, client.errors[index]
}
response := ProposalResponse{DuplicateGroups: []DuplicateGroup{}}
if index < len(client.responses) {
response = cloneProposalResponse(client.responses[index])
}
target, ok := output.(*ProposalResponse)
if !ok {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("output type = %T", output)
}
*target = response
return contracts.StructuredCompletionResponse{}, nil
}
func cloneProposalResponse(response ProposalResponse) ProposalResponse {
cloned := ProposalResponse{DuplicateGroups: make([]DuplicateGroup, len(response.DuplicateGroups))}
for index, group := range response.DuplicateGroups {
cloned.DuplicateGroups[index] = DuplicateGroup{
CandidateIDs: append([]int(nil), group.CandidateIDs...), CanonicalCandidateID: group.CanonicalCandidateID,
}
}
return cloned
}
func newTestEngine(t *testing.T, client contracts.StructuredLLMClient, limits Limits) *Engine {
t.Helper()
engine, err := NewEngine(client, testPromptSpec("a"), limits)
if err != nil {
t.Fatalf("NewEngine() error = %v", err)
}
return engine
}
func testPromptSpec(digestCharacter string) PromptSpec {
return PromptSpec{ID: "test.semantic_reconciliation", Version: "v1", SHA256: testDigest(digestCharacter)}
}
func testDigest(character string) string { return "sha256:" + strings.Repeat(character, 64) }
func readyEngineRequest() Request { return engineRequestWithCandidateCount(2) }
func engineRequestWithCandidateCount(count int) Request {
document := &source.SourceDocument{ID: "source", Units: []source.SourceUnit{
{ID: 1, Kind: "speech", Text: "Mira arrived."},
{ID: 2, Kind: "speech", Text: "The captain spoke."},
}}
candidates := make([]Candidate, count)
for index := range candidates {
unitID := index%len(document.Units) + 1
candidates[index] = Candidate{
Label: fmt.Sprintf("candidate-%d", index+1),
SourceRefs: []source.SourceRef{{SourceID: document.ID, StartUnitID: unitID, EndUnitID: unitID}},
}
}
return Request{StageName: "test/normalize", Source: document, Candidates: candidates, ProfileID: "profile", SessionID: "session"}
}
type cancelBeforeCompletionContext struct{ calls int }
func (ctx *cancelBeforeCompletionContext) Deadline() (time.Time, bool) { return time.Time{}, false }
func (ctx *cancelBeforeCompletionContext) Done() <-chan struct{} { return nil }
func (ctx *cancelBeforeCompletionContext) Value(any) any { return nil }
func (ctx *cancelBeforeCompletionContext) Err() error {
ctx.calls++
if ctx.calls > 1 {
return context.Canceled
}
return nil
}

View File

@@ -0,0 +1,101 @@
package semanticreconcile
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
// Policy identifies the framework-owned reconciliation and assessment rules.
const Policy = "semantic_reconciliation.v1"
var _ contracts.ManifestMetadataProvider = (*Engine)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Engine)(nil)
// ManifestMetadata returns fresh, content-free identity for the complete core
// reconciliation mechanism.
func (engine *Engine) ManifestMetadata() map[string]any {
if engine == nil || engine.validate() != nil {
return nil
}
return map[string]any{
"prompt_id": engine.prompt.ID,
"prompt_version": engine.prompt.Version,
"prompt_sha256": engine.prompt.SHA256,
"response_schema_key": string(engine.schema.Key),
"response_schema_id": engine.schema.ID,
"response_schema_name": engine.schema.Name,
"response_schema_version": engine.schema.Version,
"response_schema_sha256": engine.schema.SHA256,
"semantic_reconciliation_policy": Policy,
"semantic_reconciliation_limits": map[string]any{
"context_radius": engine.limits.ContextRadius,
"maximum_candidates": engine.limits.MaximumCandidates,
"maximum_material_bytes": engine.limits.MaximumMaterialBytes,
},
}
}
// CheckpointFingerprints returns fresh canonical identities for prompt,
// schema, reconciliation policy, and the complete limit policy.
func (engine *Engine) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if engine == nil || engine.validate() != nil {
return nil
}
return []pipeline.CheckpointFingerprint{
{Name: "prompt", Value: identityDigest(engine.prompt.ID, engine.prompt.Version, engine.prompt.SHA256)},
{Name: "response_schema", Value: identityDigest(string(engine.schema.Key), engine.schema.ID, engine.schema.Version, engine.schema.Name, engine.schema.SHA256)},
{Name: "semantic_reconciliation_policy", Value: Policy},
{Name: "semantic_reconciliation_limits", Value: limitPolicyDigest(engine.limits)},
}
}
func limitPolicyDigest(limits Limits) string {
return identityDigest(
strconv.Itoa(limits.ContextRadius),
strconv.Itoa(limits.MaximumCandidates),
strconv.Itoa(limits.MaximumMaterialBytes),
)
}
func identityDigest(parts ...string) string {
hash := sha256.New()
for _, part := range parts {
_, _ = hash.Write([]byte(strconv.Itoa(len(part))))
_, _ = hash.Write([]byte{':'})
_, _ = hash.Write([]byte(part))
}
return "sha256:" + hex.EncodeToString(hash.Sum(nil))
}
func validateResponseSchemaIdentity(schema llm.ResponseSchema) error {
if strings.TrimSpace(string(schema.Key)) == "" || strings.TrimSpace(schema.ID) == "" || strings.TrimSpace(schema.Version) == "" || strings.TrimSpace(schema.Name) == "" {
return fmt.Errorf("response schema identity must be complete")
}
if err := validateSHA256(schema.SHA256); err != nil {
return fmt.Errorf("response schema digest: %w", err)
}
return nil
}
func validateSHA256(value string) error {
const prefix = "sha256:"
if !strings.HasPrefix(value, prefix) {
return fmt.Errorf("must use sha256: prefix")
}
hexValue := strings.TrimPrefix(value, prefix)
if len(hexValue) != sha256.Size*2 || hexValue != strings.ToLower(hexValue) {
return fmt.Errorf("must contain 64 lowercase hexadecimal characters")
}
decoded, err := hex.DecodeString(hexValue)
if err != nil || len(decoded) != sha256.Size {
return fmt.Errorf("must contain 64 lowercase hexadecimal characters")
}
return nil
}

View File

@@ -0,0 +1,93 @@
package semanticreconcile
import (
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestEngineMetadataAndFingerprintsCoverCoreIdentity(t *testing.T) {
base := newTestEngine(t, &recordingReconciliationClient{}, DefaultLimits())
metadata := base.ManifestMetadata()
for _, key := range []string{
"prompt_id", "prompt_version", "prompt_sha256",
"response_schema_key", "response_schema_id", "response_schema_name", "response_schema_version", "response_schema_sha256",
"semantic_reconciliation_policy", "semantic_reconciliation_limits",
} {
if metadata[key] == nil || metadata[key] == "" {
t.Fatalf("metadata[%q] = %#v, want populated core identity", key, metadata[key])
}
}
limits, ok := metadata["semantic_reconciliation_limits"].(map[string]any)
if !ok || len(limits) != 3 || limits["context_radius"] == nil || limits["maximum_candidates"] == nil || limits["maximum_material_bytes"] == nil {
t.Fatalf("limit metadata = %#v, want complete limits", metadata["semantic_reconciliation_limits"])
}
fingerprints := base.CheckpointFingerprints()
wantNames := []string{"prompt", "response_schema", "semantic_reconciliation_policy", "semantic_reconciliation_limits"}
if len(fingerprints) != len(wantNames) {
t.Fatalf("fingerprints = %#v, want required categories", fingerprints)
}
for index, want := range wantNames {
if fingerprints[index].Name != want || fingerprints[index].Value == "" {
t.Fatalf("fingerprint %d = %#v, want %q with value", index, fingerprints[index], want)
}
}
metadata["prompt_id"] = "changed"
limits["context_radius"] = -1
fingerprints[0].Name = "changed"
if got := base.ManifestMetadata(); got["prompt_id"] == "changed" || got["semantic_reconciliation_limits"].(map[string]any)["context_radius"] == -1 {
t.Fatalf("ManifestMetadata() exposed retained state: %#v", got)
}
if got := base.CheckpointFingerprints(); got[0].Name == "changed" {
t.Fatalf("CheckpointFingerprints() exposed retained state: %#v", got)
}
}
func TestCoreFingerprintsChangeWithBehavioralIdentity(t *testing.T) {
base := newTestEngine(t, &recordingReconciliationClient{}, DefaultLimits())
baseFingerprints := base.CheckpointFingerprints()
promptChanged, err := NewEngine(&recordingReconciliationClient{}, testPromptSpec("b"), DefaultLimits())
if err != nil {
t.Fatal(err)
}
assertOnlyFingerprintChanged(t, baseFingerprints, promptChanged.CheckpointFingerprints(), "prompt")
schema, err := LoadResponseSchema()
if err != nil {
t.Fatal(err)
}
schema.SHA256 = testDigest("b")
schemaChanged := newEngine(&recordingReconciliationClient{}, base.prompt, schema, DefaultLimits())
assertOnlyFingerprintChanged(t, baseFingerprints, schemaChanged.CheckpointFingerprints(), "response_schema")
limits := DefaultLimits()
limits.ContextRadius++
limitsChanged, err := NewEngine(&recordingReconciliationClient{}, base.prompt, limits)
if err != nil {
t.Fatal(err)
}
assertOnlyFingerprintChanged(t, baseFingerprints, limitsChanged.CheckpointFingerprints(), "semantic_reconciliation_limits")
}
func TestInvalidEngineIdentityHasNoMetadataOrFingerprints(t *testing.T) {
invalid := newEngine(&recordingReconciliationClient{}, testPromptSpec("a"), llm.ResponseSchema{}, DefaultLimits())
if invalid.ManifestMetadata() != nil || invalid.CheckpointFingerprints() != nil {
t.Fatalf("invalid engine exposed identity: metadata %#v fingerprints %#v", invalid.ManifestMetadata(), invalid.CheckpointFingerprints())
}
}
func assertOnlyFingerprintChanged(t *testing.T, before, after []pipeline.CheckpointFingerprint, changedName string) {
t.Helper()
if len(before) != len(after) {
t.Fatalf("fingerprint counts differ: %#v %#v", before, after)
}
for index := range before {
changed := before[index] != after[index]
if changed != (before[index].Name == changedName) {
t.Fatalf("fingerprint %q change = %t, want only %q changed\nbefore: %#v\nafter: %#v", before[index].Name, changed, changedName, before, after)
}
}
}

View File

@@ -0,0 +1,403 @@
package semanticreconcile
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
const (
candidateInputName = "candidates"
transcriptInputName = "transcript"
jsonMediaType = "application/json"
)
var defaultLimits = Limits{
ContextRadius: 2,
MaximumCandidates: 128,
MaximumMaterialBytes: 262144,
}
// Candidate is contextual source-backed input supplied by a typed consumer.
// Prepare does not retain or mutate Label or SourceRefs.
type Candidate struct {
Label string
SourceRefs []source.SourceRef
}
// Limits bounds source context and serialized model input.
type Limits struct {
ContextRadius int
MaximumCandidates int
MaximumMaterialBytes int
}
// DefaultLimits returns the core-owned production limits.
func DefaultLimits() Limits {
return defaultLimits
}
// Validate rejects limits that cannot safely bound preparation.
func (limits Limits) Validate() error {
if limits.ContextRadius < 0 {
return fmt.Errorf("semantic reconciliation limits: context radius must not be negative")
}
if limits.MaximumCandidates <= 0 {
return fmt.Errorf("semantic reconciliation limits: maximum candidates must be positive")
}
if limits.MaximumMaterialBytes <= 0 {
return fmt.Errorf("semantic reconciliation limits: maximum bytes must be positive")
}
return nil
}
// Disposition describes whether prepared materials may be sent to a model.
type Disposition uint8
const (
// Ready indicates that the result contains complete bounded materials.
Ready Disposition = iota + 1
// InsufficientCandidates indicates that fewer than two candidates were
// eligible after source-reference validation.
InsufficientCandidates
// LimitExceeded indicates that a candidate or serialized-material bound was
// exceeded and no request should be split or sent.
LimitExceeded
)
// CandidateMapping relates one model-visible request-local ID to the
// corresponding zero-based position in the caller's candidate slice.
type CandidateMapping struct {
CandidateID int
CandidatePosition int
}
// Preparation owns the visible candidate mapping and prompt materials.
type Preparation struct {
disposition Disposition
mappings []CandidateMapping
materials contracts.LLMInputSet
}
// Disposition returns the preparation outcome.
func (preparation Preparation) Disposition() Disposition {
return preparation.disposition
}
// CandidateMappings returns an owned copy in model-visible candidate order.
func (preparation Preparation) CandidateMappings() []CandidateMapping {
return append([]CandidateMapping(nil), preparation.mappings...)
}
// Materials returns independently owned candidate and transcript materials.
// It is empty unless Disposition returns Ready.
func (preparation Preparation) Materials() contracts.LLMInputSet {
return preparation.materials.Clone()
}
type sourceRange struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
}
type visibleCandidate struct {
CandidateID int `json:"candidate_id"`
Label string `json:"label"`
SourceRefs []sourceRange `json:"source_refs"`
}
type candidateInput struct {
Candidates []visibleCandidate `json:"candidates"`
}
type transcriptInput struct {
Windows []transcriptWindow `json:"windows"`
}
type transcriptWindow struct {
Units []transcriptUnit `json:"units"`
}
type transcriptUnit struct {
ID int `json:"id"`
Kind string `json:"kind"`
Text string `json:"text"`
Metadata map[string]any `json:"metadata,omitempty"`
Cited bool `json:"cited"`
}
type sourceInterval struct {
start int
end int
}
type preparedCandidate struct {
position int
references []sourceRange
intervals []sourceInterval
}
// Prepare validates candidates and constructs bounded, source-ordered model
// inputs. Deterministic skip conditions are represented by the returned
// disposition rather than an error.
func Prepare(document *source.SourceDocument, candidates []Candidate, limits Limits) (Preparation, error) {
if err := limits.Validate(); err != nil {
return Preparation{}, err
}
documentIndex := source.NewDocumentIndex(document)
prepared := make([]preparedCandidate, 0, len(candidates))
for candidatePosition, candidate := range candidates {
references, intervals, valid := prepareReferences(documentIndex, candidate.SourceRefs)
if !valid {
continue
}
prepared = append(prepared, preparedCandidate{
position: candidatePosition,
references: references,
intervals: intervals,
})
}
result := Preparation{
disposition: InsufficientCandidates,
mappings: make([]CandidateMapping, len(prepared)),
}
views := make([]visibleCandidate, len(prepared))
for index, candidate := range prepared {
candidateID := index + 1
result.mappings[index] = CandidateMapping{
CandidateID: candidateID,
CandidatePosition: candidate.position,
}
views[index] = visibleCandidate{
CandidateID: candidateID,
Label: candidates[candidate.position].Label,
SourceRefs: cloneSourceRanges(candidate.references),
}
}
if len(prepared) < 2 {
return result, nil
}
if len(prepared) > limits.MaximumCandidates {
result.disposition = LimitExceeded
return result, nil
}
candidateContent, withinLimit, err := marshalCandidateInput(views, limits.MaximumMaterialBytes)
if err != nil {
return Preparation{}, fmt.Errorf("prepare semantic reconciliation: encode candidate material: %w", err)
}
if !withinLimit {
result.disposition = LimitExceeded
return result, nil
}
contextIntervals := make([]sourceInterval, 0)
citedIntervals := make([]sourceInterval, 0)
for _, candidate := range prepared {
for _, interval := range candidate.intervals {
citedIntervals = append(citedIntervals, interval)
contextIntervals = append(contextIntervals, sourceInterval{
start: max(0, interval.start-limits.ContextRadius),
end: min(len(document.Units)-1, interval.end+limits.ContextRadius),
})
}
}
transcriptContent, withinLimit, err := marshalTranscriptInput(
document.Units,
coalesceIntervals(contextIntervals),
coalesceIntervals(citedIntervals),
limits.MaximumMaterialBytes-len(candidateContent),
)
if err != nil {
return Preparation{}, fmt.Errorf("prepare semantic reconciliation: build transcript material: invalid source metadata")
}
if !withinLimit {
result.disposition = LimitExceeded
return result, nil
}
result.disposition = Ready
result.materials = contracts.LLMInputSet{
candidateInputName: newInputMaterial(candidateInputName, candidateContent),
transcriptInputName: newInputMaterial(transcriptInputName, transcriptContent),
}
return result, nil
}
func prepareReferences(index source.DocumentIndex, references []source.SourceRef) ([]sourceRange, []sourceInterval, bool) {
if len(references) == 0 {
return nil, nil, false
}
type referencedInterval struct {
reference sourceRange
interval sourceInterval
}
prepared := make([]referencedInterval, 0, len(references))
for _, reference := range references {
if err := index.ValidateRef(reference); err != nil {
return nil, nil, false
}
start, _ := index.Position(reference.StartUnitID)
end, _ := index.Position(reference.EndUnitID)
prepared = append(prepared, referencedInterval{
reference: sourceRange{StartUnitID: reference.StartUnitID, EndUnitID: reference.EndUnitID},
interval: sourceInterval{start: start, end: end},
})
}
sort.Slice(prepared, func(left, right int) bool {
if prepared[left].interval.start != prepared[right].interval.start {
return prepared[left].interval.start < prepared[right].interval.start
}
return prepared[left].interval.end < prepared[right].interval.end
})
canonicalReferences := make([]sourceRange, 0, len(prepared))
intervals := make([]sourceInterval, 0, len(prepared))
for _, item := range prepared {
if len(canonicalReferences) > 0 && canonicalReferences[len(canonicalReferences)-1] == item.reference {
continue
}
canonicalReferences = append(canonicalReferences, item.reference)
intervals = append(intervals, item.interval)
}
return canonicalReferences, intervals, true
}
func cloneSourceRanges(ranges []sourceRange) []sourceRange {
if len(ranges) == 0 {
return []sourceRange{}
}
return append([]sourceRange(nil), ranges...)
}
func coalesceIntervals(intervals []sourceInterval) []sourceInterval {
if len(intervals) == 0 {
return nil
}
ordered := append([]sourceInterval(nil), intervals...)
sort.Slice(ordered, func(left, right int) bool {
if ordered[left].start != ordered[right].start {
return ordered[left].start < ordered[right].start
}
return ordered[left].end < ordered[right].end
})
coalesced := make([]sourceInterval, 0, len(ordered))
for _, interval := range ordered {
if len(coalesced) == 0 || interval.start > coalesced[len(coalesced)-1].end+1 {
coalesced = append(coalesced, interval)
continue
}
if interval.end > coalesced[len(coalesced)-1].end {
coalesced[len(coalesced)-1].end = interval.end
}
}
return coalesced
}
func marshalCandidateInput(candidates []visibleCandidate, maximumBytes int) ([]byte, bool, error) {
content := make([]byte, 0, min(maximumBytes, 4096))
var withinLimit bool
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 citedIndex < len(citedIntervals) && citedIntervals[citedIndex].end < position {
citedIndex++
}
cited := citedIndex < len(citedIntervals) && citedIntervals[citedIndex].start <= position
unit := units[position]
metadata, err := source.CloneMetadata(unit.Metadata)
if err != nil {
return nil, false, err
}
encoded, err := json.Marshal(transcriptUnit{
ID: unit.ID, Kind: unit.Kind, Text: unit.Text, Metadata: metadata, Cited: cited,
})
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
}
}
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 {
digest := sha256.Sum256(content)
return contracts.NewLLMInputMaterial(
name,
jsonMediaType,
content,
"sha256:"+hex.EncodeToString(digest[:]),
"",
)
}

View File

@@ -0,0 +1,386 @@
package semanticreconcile
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"math"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
func TestPrepareBuildsContiguousCandidatesAndOwnedSourceContext(t *testing.T) {
document := &source.SourceDocument{ID: "private-source-id", Units: []source.SourceUnit{
{ID: 40, Kind: "narration", Text: "zero"},
{ID: 10, Kind: "speech", Text: "one", Metadata: map[string]any{"speaker": map[string]any{"name": "Mira"}}},
{ID: 70, Kind: "speech", Text: "two"},
{ID: 20, Kind: "narration", Text: "three"},
{ID: 90, Kind: "speech", Text: "four"},
}}
references := []source.SourceRef{
{SourceID: document.ID, StartUnitID: 90, EndUnitID: 90},
{SourceID: document.ID, StartUnitID: 10, EndUnitID: 20},
{SourceID: document.ID, StartUnitID: 10, EndUnitID: 20},
}
candidates := []Candidate{
{Label: "The Tavern", SourceRefs: append([]source.SourceRef(nil), references...)},
{Label: "The Tavern", SourceRefs: append([]source.SourceRef(nil), references...)},
{Label: "Broken", SourceRefs: []source.SourceRef{{SourceID: document.ID, StartUnitID: 20, EndUnitID: 10}}},
}
before := cloneCandidates(candidates)
preparation, err := Prepare(document, candidates, Limits{
ContextRadius: 1,
MaximumCandidates: len(candidates),
MaximumMaterialBytes: 10000,
})
if err != nil {
t.Fatal(err)
}
if preparation.Disposition() != Ready {
t.Fatalf("Disposition() = %v, want Ready", preparation.Disposition())
}
if !reflect.DeepEqual(candidates, before) {
t.Fatalf("Prepare() mutated candidates: %#v", candidates)
}
if got, want := preparation.CandidateMappings(), []CandidateMapping{
{CandidateID: 1, CandidatePosition: 0},
{CandidateID: 2, CandidatePosition: 1},
}; !reflect.DeepEqual(got, want) {
t.Fatalf("CandidateMappings() = %#v, want %#v", got, want)
}
materials := preparation.Materials()
if len(materials) != 2 {
t.Fatalf("materials = %#v, want candidates and transcript", materials)
}
if _, ok := materials[candidateInputName]; !ok {
t.Fatal("candidate material is missing")
}
if _, ok := materials[transcriptInputName]; !ok {
t.Fatal("transcript material is missing")
}
for name, material := range materials {
if material.Name != name || material.MediaType != "application/json" || material.OriginURI != "" || material.SizeBytes != int64(len(material.Content)) {
t.Fatalf("material %q metadata = %#v", name, material)
}
digest := sha256.Sum256(material.Content)
if want := "sha256:" + hex.EncodeToString(digest[:]); material.Digest != want {
t.Fatalf("material %q digest = %q, want %q", name, material.Digest, want)
}
}
candidateContent := append([]byte(nil), materials[candidateInputName].Content...)
var candidatePayload candidateInput
if err := json.Unmarshal(candidateContent, &candidatePayload); err != nil {
t.Fatal(err)
}
wantCandidates := []visibleCandidate{
{CandidateID: 1, Label: "The Tavern", SourceRefs: []sourceRange{{StartUnitID: 10, EndUnitID: 20}, {StartUnitID: 90, EndUnitID: 90}}},
{CandidateID: 2, Label: "The Tavern", SourceRefs: []sourceRange{{StartUnitID: 10, EndUnitID: 20}, {StartUnitID: 90, EndUnitID: 90}}},
}
if !reflect.DeepEqual(candidatePayload.Candidates, wantCandidates) {
t.Fatalf("candidate payload = %#v, want %#v", candidatePayload.Candidates, wantCandidates)
}
var candidateObjects struct {
Candidates []map[string]json.RawMessage `json:"candidates"`
}
if err := json.Unmarshal(candidateContent, &candidateObjects); err != nil {
t.Fatal(err)
}
for _, candidate := range candidateObjects.Candidates {
if len(candidate) != 3 || candidate["candidate_id"] == nil || candidate["label"] == nil || candidate["source_refs"] == nil {
t.Fatalf("model-facing candidate fields = %#v", candidate)
}
}
combined := string(materials[candidateInputName].Content) + string(materials[transcriptInputName].Content)
for _, forbidden := range []string{document.ID, "application_entity_id", "private-entity-id"} {
if strings.Contains(combined, forbidden) {
t.Fatalf("model material leaked %q: %s", forbidden, combined)
}
}
var transcript transcriptInput
if err := json.Unmarshal(materials[transcriptInputName].Content, &transcript); err != nil {
t.Fatal(err)
}
if len(transcript.Windows) != 1 || len(transcript.Windows[0].Units) != len(document.Units) {
t.Fatalf("windows = %#v, want one coalesced source window", transcript.Windows)
}
for index, wantID := range []int{40, 10, 70, 20, 90} {
if transcript.Windows[0].Units[index].ID != wantID {
t.Fatalf("unit %d id = %d, want %d", index, transcript.Windows[0].Units[index].ID, wantID)
}
}
if transcript.Windows[0].Units[0].Cited {
t.Fatal("radius-only unit marked cited")
}
for index := 1; index < len(transcript.Windows[0].Units); index++ {
if !transcript.Windows[0].Units[index].Cited {
t.Fatalf("evidence unit %d was not marked cited", index)
}
}
candidates[0].SourceRefs[0].StartUnitID = 40
document.Units[1].Metadata["speaker"].(map[string]any)["name"] = "changed"
if got := preparation.Materials()[candidateInputName].Content; !reflect.DeepEqual(got, candidateContent) {
t.Fatalf("candidate material changed through caller input: %s", got)
}
var retained transcriptInput
if err := json.Unmarshal(preparation.Materials()[transcriptInputName].Content, &retained); err != nil {
t.Fatal(err)
}
if got := retained.Windows[0].Units[1].Metadata["speaker"].(map[string]any)["name"]; got != "Mira" {
t.Fatalf("retained metadata = %v, want Mira", got)
}
returnedMappings := preparation.CandidateMappings()
returnedMappings[0].CandidatePosition = 99
returnedMaterials := preparation.Materials()
candidateMaterial := returnedMaterials[candidateInputName]
candidateMaterial.Content[0] = '['
returnedMaterials[candidateInputName] = candidateMaterial
delete(returnedMaterials, transcriptInputName)
if preparation.CandidateMappings()[0].CandidatePosition != 0 || !json.Valid(preparation.Materials()[candidateInputName].Content) || len(preparation.Materials()) != 2 {
t.Fatal("preparation accessors exposed retained data")
}
}
func TestPrepareRedactsInvalidSourceMetadata(t *testing.T) {
const sensitiveKey = "sensitive-metadata-key"
document := &source.SourceDocument{ID: "private-source", Units: []source.SourceUnit{
{ID: 1, Text: "private transcript", Metadata: map[string]any{sensitiveKey: math.NaN()}},
{ID: 2, Text: "other private transcript"},
}}
candidates := []Candidate{
{Label: "Private One", SourceRefs: []source.SourceRef{{SourceID: document.ID, StartUnitID: 1, EndUnitID: 1}}},
{Label: "Private Two", SourceRefs: []source.SourceRef{{SourceID: document.ID, StartUnitID: 2, EndUnitID: 2}}},
}
_, err := Prepare(document, candidates, DefaultLimits())
if err == nil || !strings.Contains(err.Error(), "invalid source metadata") {
t.Fatalf("Prepare() error = %v, want redacted metadata failure", err)
}
for _, forbidden := range []string{sensitiveKey, document.ID, document.Units[0].Text, candidates[0].Label, "non-finite", "float64"} {
if strings.Contains(err.Error(), forbidden) {
t.Fatalf("Prepare() error leaked %q: %v", forbidden, err)
}
}
}
func TestPrepareFiltersUnsafeCandidatesAndCoalescesAdjacentWindows(t *testing.T) {
document := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 9}, {ID: 3}, {ID: 8}, {ID: 1}, {ID: 7},
}}
candidates := []Candidate{
{Label: "One", SourceRefs: []source.SourceRef{{SourceID: document.ID, StartUnitID: 3, EndUnitID: 3}}},
{Label: "Two", SourceRefs: []source.SourceRef{{SourceID: document.ID, StartUnitID: 8, EndUnitID: 8}}},
{Label: "Missing", SourceRefs: []source.SourceRef{{SourceID: document.ID, StartUnitID: 99, EndUnitID: 99}}},
{Label: "Foreign", SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: 1, EndUnitID: 1}}},
{Label: "No references"},
{Label: "Partly invalid", SourceRefs: []source.SourceRef{
{SourceID: document.ID, StartUnitID: 1, EndUnitID: 1},
{SourceID: document.ID, StartUnitID: 100, EndUnitID: 100},
}},
}
limits := Limits{ContextRadius: 0, MaximumCandidates: len(candidates), MaximumMaterialBytes: 10000}
preparation, err := Prepare(document, candidates, limits)
if err != nil || preparation.Disposition() != Ready {
t.Fatalf("Prepare() disposition = %v, error = %v", preparation.Disposition(), err)
}
if got, want := preparation.CandidateMappings(), []CandidateMapping{
{CandidateID: 1, CandidatePosition: 0},
{CandidateID: 2, CandidatePosition: 1},
}; !reflect.DeepEqual(got, want) {
t.Fatalf("CandidateMappings() = %#v, want %#v", got, want)
}
var transcript transcriptInput
if err := json.Unmarshal(preparation.Materials()[transcriptInputName].Content, &transcript); err != nil {
t.Fatal(err)
}
if len(transcript.Windows) != 1 || len(transcript.Windows[0].Units) != 2 || transcript.Windows[0].Units[0].ID != 3 || transcript.Windows[0].Units[1].ID != 8 {
t.Fatalf("windows = %#v, want adjacent source-order units coalesced", transcript.Windows)
}
oneCandidate, err := Prepare(document, candidates[:1], limits)
if err != nil {
t.Fatal(err)
}
if got, want := oneCandidate.CandidateMappings(), []CandidateMapping{{CandidateID: 1, CandidatePosition: 0}}; oneCandidate.Disposition() != InsufficientCandidates || !reflect.DeepEqual(got, want) || len(oneCandidate.Materials()) != 0 {
t.Fatalf("Prepare(one candidate) = disposition %v, mappings %#v, materials %#v", oneCandidate.Disposition(), got, oneCandidate.Materials())
}
nilPreparation, err := Prepare(nil, candidates, limits)
if err != nil {
t.Fatal(err)
}
if nilPreparation.Disposition() != InsufficientCandidates || len(nilPreparation.CandidateMappings()) != 0 || len(nilPreparation.Materials()) != 0 {
t.Fatalf("Prepare(nil) = disposition %v, mappings %#v, materials %#v", nilPreparation.Disposition(), nilPreparation.CandidateMappings(), nilPreparation.Materials())
}
}
func TestPrepareValidatesLimitsBeforeBuildingMaterials(t *testing.T) {
if err := DefaultLimits().Validate(); err != nil {
t.Fatalf("DefaultLimits().Validate() error = %v", err)
}
cycle := map[string]any{}
cycle["self"] = cycle
document := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 1, Metadata: cycle}, {ID: 2},
}}
candidates := candidatesForEveryUnit(document)
tests := []struct {
name string
limits Limits
want string
}{
{name: "negative radius", limits: Limits{ContextRadius: -1, MaximumCandidates: 2, MaximumMaterialBytes: 100}, want: "radius"},
{name: "zero candidates", limits: Limits{ContextRadius: 0, MaximumCandidates: 0, MaximumMaterialBytes: 100}, want: "candidates"},
{name: "negative candidates", limits: Limits{ContextRadius: 0, MaximumCandidates: -1, MaximumMaterialBytes: 100}, want: "candidates"},
{name: "zero bytes", limits: Limits{ContextRadius: 0, MaximumCandidates: 2, MaximumMaterialBytes: 0}, want: "bytes"},
{name: "negative bytes", limits: Limits{ContextRadius: 0, MaximumCandidates: 2, MaximumMaterialBytes: -1}, want: "bytes"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := Prepare(document, candidates, test.limits); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Prepare() error = %v, want %q validation", err, test.want)
}
})
}
}
func TestPrepareEnforcesCandidateLimitBeforeRenderingContext(t *testing.T) {
cycle := map[string]any{}
cycle["self"] = cycle
document := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 1, Text: "one", Metadata: cycle},
{ID: 2, Text: "two"},
{ID: 3, Text: "three"},
}}
candidates := candidatesForEveryUnit(document)
limits := Limits{ContextRadius: 0, MaximumCandidates: 2, MaximumMaterialBytes: 10000}
exceeded, err := Prepare(document, candidates, limits)
if err != nil {
t.Fatalf("Prepare(over limit) error = %v; context should not be rendered", err)
}
if exceeded.Disposition() != LimitExceeded || len(exceeded.CandidateMappings()) != 3 || len(exceeded.Materials()) != 0 {
t.Fatalf("Prepare(over limit) = disposition %v, mappings %#v, materials %#v", exceeded.Disposition(), exceeded.CandidateMappings(), exceeded.Materials())
}
document.Units[0].Metadata = nil
exact, err := Prepare(document, candidates[:2], limits)
if err != nil || exact.Disposition() != Ready {
t.Fatalf("Prepare(at limit) = disposition %v, error %v", exact.Disposition(), err)
}
}
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) {
document := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 1, Text: "one"},
{ID: 2, Text: "two"},
}}
candidates := candidatesForEveryUnit(document)
baseLimits := Limits{ContextRadius: 0, MaximumCandidates: len(candidates), MaximumMaterialBytes: 10000}
base, err := Prepare(document, candidates, baseLimits)
if err != nil || base.Disposition() != Ready {
t.Fatalf("Prepare(base) = disposition %v, error %v", base.Disposition(), err)
}
materials := base.Materials()
totalBytes := len(materials[candidateInputName].Content) + len(materials[transcriptInputName].Content)
exactLimits := baseLimits
exactLimits.MaximumMaterialBytes = totalBytes
exact, err := Prepare(document, candidates, exactLimits)
if err != nil || exact.Disposition() != Ready {
t.Fatalf("Prepare(exact bytes) = disposition %v, error %v", exact.Disposition(), err)
}
oneOverLimits := exactLimits
oneOverLimits.MaximumMaterialBytes--
oneOver, err := Prepare(document, candidates, oneOverLimits)
if err != nil || oneOver.Disposition() != LimitExceeded || len(oneOver.Materials()) != 0 {
t.Fatalf("Prepare(one over) = disposition %v, materials %#v, error %v", oneOver.Disposition(), oneOver.Materials(), err)
}
}
func TestPrepareSerializationIsDeterministic(t *testing.T) {
document := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 5, Text: "five", Metadata: map[string]any{"z": 1, "a": []any{"first", "second"}}},
{ID: 2, Text: "two", Metadata: map[string]any{"nested": map[string]any{"b": true, "a": false}}},
}}
candidates := candidatesForEveryUnit(document)
limits := Limits{ContextRadius: 0, MaximumCandidates: len(candidates), MaximumMaterialBytes: 10000}
first, err := Prepare(document, candidates, limits)
if err != nil {
t.Fatal(err)
}
second, err := Prepare(document, cloneCandidates(candidates), limits)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{candidateInputName, transcriptInputName} {
firstMaterial := first.Materials()[name]
secondMaterial := second.Materials()[name]
if !reflect.DeepEqual(firstMaterial, secondMaterial) {
t.Fatalf("material %q is not deterministic:\n%#v\n%#v", name, firstMaterial, secondMaterial)
}
}
}
func candidatesForEveryUnit(document *source.SourceDocument) []Candidate {
candidates := make([]Candidate, len(document.Units))
for index, unit := range document.Units {
candidates[index] = Candidate{
Label: "candidate",
SourceRefs: []source.SourceRef{{
SourceID: document.ID,
StartUnitID: unit.ID,
EndUnitID: unit.ID,
}},
}
}
return candidates
}
func cloneCandidates(candidates []Candidate) []Candidate {
cloned := append([]Candidate(nil), candidates...)
for index := range cloned {
cloned[index].SourceRefs = append([]source.SourceRef(nil), candidates[index].SourceRefs...)
}
return cloned
}

View File

@@ -0,0 +1,236 @@
package semanticreconcile
import (
"fmt"
"sort"
)
// ProposalResponse is the complete private structured response contract.
type ProposalResponse struct {
DuplicateGroups []DuplicateGroup `json:"duplicate_groups"`
}
// DuplicateGroup proposes supplied request-local candidate IDs that may denote
// one entity and identifies one supplied member as canonical.
type DuplicateGroup struct {
CandidateIDs []int `json:"candidate_ids"`
CanonicalCandidateID int `json:"canonical_candidate_id"`
}
// IssueCategory identifies one stable proposal safety failure.
type IssueCategory string
const (
IssueMemberNonPositive IssueCategory = "member_non_positive"
IssueMemberUnknown IssueCategory = "member_unknown"
IssueRepeatedMember IssueCategory = "repeated_member"
IssueFewerThanTwoMembers IssueCategory = "fewer_than_two_members"
IssueCanonicalNonPositive IssueCategory = "canonical_non_positive"
IssueCanonicalUnknown IssueCategory = "canonical_unknown"
IssueCanonicalNotMember IssueCategory = "canonical_not_member"
IssueOverlappingMember IssueCategory = "overlapping_member"
)
// Issue identifies an unsafe proposal category at its original response group
// index without prescribing caller warning text.
type Issue struct {
GroupIndex int
Category IssueCategory
}
// IssueDetails renders stable, domain-neutral proposal diagnostics for an
// adapter's retry message.
func IssueDetails(issues []Issue) []string {
details := make([]string, len(issues))
for index, issue := range issues {
details[index] = fmt.Sprintf("group %d: %s", issue.GroupIndex, issue.Category)
}
return details
}
// PlanGroup identifies one validated group using original candidate positions.
type PlanGroup struct {
memberPositions []int
canonicalPosition int
}
// MemberPositions returns an owned, ascending list of original candidate
// positions.
func (group PlanGroup) MemberPositions() []int {
return append([]int(nil), group.memberPositions...)
}
// CanonicalPosition returns the original position of the selected canonical
// candidate.
func (group PlanGroup) CanonicalPosition() int {
return group.canonicalPosition
}
// Plan contains deterministic, non-overlapping reconciliation groups.
type Plan struct {
groups []PlanGroup
}
// Groups returns a deeply owned copy ordered by each group's earliest member.
func (plan Plan) Groups() []PlanGroup {
groups := make([]PlanGroup, len(plan.groups))
for index, group := range plan.groups {
groups[index] = clonePlanGroup(group)
}
return groups
}
// Assessment contains the safe plan and stable diagnostics for discarded
// response groups.
type Assessment struct {
plan Plan
discardedGroupCount int
issues []Issue
}
// Plan returns an independently owned reconciliation plan.
func (assessment Assessment) Plan() Plan {
groups := assessment.plan.Groups()
return Plan{groups: groups}
}
// Issues returns an owned copy ordered by original response group index.
func (assessment Assessment) Issues() []Issue {
return append([]Issue(nil), assessment.issues...)
}
// DiscardedGroupCount returns the number of response groups excluded from the
// safe plan.
func (assessment Assessment) DiscardedGroupCount() int {
return assessment.discardedGroupCount
}
// RetryRequired reports whether any response group was discarded.
func (assessment Assessment) RetryRequired() bool {
return assessment.discardedGroupCount > 0
}
type assessedGroup struct {
memberPositions []int
canonicalPosition int
issues []IssueCategory
locallyValid bool
conflicting bool
}
// Assess resolves request-local IDs through the retained preparation mapping
// and returns only deterministic, non-overlapping groups.
func (preparation Preparation) Assess(response ProposalResponse) Assessment {
positionsByID := make(map[int]int, len(preparation.mappings))
for _, mapping := range preparation.mappings {
positionsByID[mapping.CandidateID] = mapping.CandidatePosition
}
groups := make([]assessedGroup, len(response.DuplicateGroups))
owners := make(map[int][]int)
for groupIndex, proposal := range response.DuplicateGroups {
groups[groupIndex] = assessGroup(proposal, positionsByID)
if !groups[groupIndex].locallyValid {
continue
}
for _, position := range groups[groupIndex].memberPositions {
owners[position] = append(owners[position], groupIndex)
}
}
for _, groupIndexes := range owners {
if len(groupIndexes) < 2 {
continue
}
for _, groupIndex := range groupIndexes {
groups[groupIndex].conflicting = true
}
}
assessment := Assessment{}
for groupIndex, group := range groups {
for _, category := range group.issues {
assessment.issues = append(assessment.issues, Issue{GroupIndex: groupIndex, Category: category})
}
if group.conflicting {
assessment.issues = append(assessment.issues, Issue{GroupIndex: groupIndex, Category: IssueOverlappingMember})
}
if !group.locallyValid || group.conflicting {
assessment.discardedGroupCount++
continue
}
assessment.plan.groups = append(assessment.plan.groups, PlanGroup{
memberPositions: append([]int(nil), group.memberPositions...),
canonicalPosition: group.canonicalPosition,
})
}
sort.Slice(assessment.plan.groups, func(left, right int) bool {
return assessment.plan.groups[left].memberPositions[0] < assessment.plan.groups[right].memberPositions[0]
})
return assessment
}
func assessGroup(proposal DuplicateGroup, positionsByID map[int]int) assessedGroup {
group := assessedGroup{}
seenIDs := make(map[int]struct{}, len(proposal.CandidateIDs))
memberPositions := make(map[int]struct{}, len(proposal.CandidateIDs))
for _, candidateID := range proposal.CandidateIDs {
if _, repeated := seenIDs[candidateID]; repeated {
group.issues = append(group.issues, IssueRepeatedMember)
continue
}
seenIDs[candidateID] = struct{}{}
position, category := resolveMember(candidateID, positionsByID)
if category != "" {
group.issues = append(group.issues, category)
continue
}
memberPositions[position] = struct{}{}
group.memberPositions = append(group.memberPositions, position)
}
if len(memberPositions) < 2 {
group.issues = append(group.issues, IssueFewerThanTwoMembers)
}
canonicalPosition, canonicalCategory := resolveCanonical(proposal.CanonicalCandidateID, positionsByID)
if canonicalCategory != "" {
group.issues = append(group.issues, canonicalCategory)
} else {
group.canonicalPosition = canonicalPosition
if _, member := memberPositions[canonicalPosition]; !member {
group.issues = append(group.issues, IssueCanonicalNotMember)
}
}
sort.Ints(group.memberPositions)
group.locallyValid = len(group.issues) == 0
return group
}
func resolveMember(candidateID int, positionsByID map[int]int) (int, IssueCategory) {
if candidateID <= 0 {
return 0, IssueMemberNonPositive
}
position, exists := positionsByID[candidateID]
if !exists {
return 0, IssueMemberUnknown
}
return position, ""
}
func resolveCanonical(candidateID int, positionsByID map[int]int) (int, IssueCategory) {
if candidateID <= 0 {
return 0, IssueCanonicalNonPositive
}
position, exists := positionsByID[candidateID]
if !exists {
return 0, IssueCanonicalUnknown
}
return position, ""
}
func clonePlanGroup(group PlanGroup) PlanGroup {
return PlanGroup{
memberPositions: append([]int(nil), group.memberPositions...),
canonicalPosition: group.canonicalPosition,
}
}

View File

@@ -0,0 +1,199 @@
package semanticreconcile
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
func TestAssessProducesAStableOriginalPositionPlan(t *testing.T) {
preparation := proposalPreparation(t)
response := ProposalResponse{DuplicateGroups: []DuplicateGroup{
{CandidateIDs: []int{5, 4}, CanonicalCandidateID: 5},
{CandidateIDs: []int{2, 1}, CanonicalCandidateID: 2},
}}
assessment := preparation.Assess(response)
want := []planGroupSnapshot{
{members: []int{0, 2}, canonical: 2},
{members: []int{5, 6}, canonical: 6},
}
if got := snapshotPlan(assessment.Plan()); !reflect.DeepEqual(got, want) {
t.Fatalf("plan = %#v, want %#v", got, want)
}
if assessment.DiscardedGroupCount() != 0 || assessment.RetryRequired() || len(assessment.Issues()) != 0 {
t.Fatalf("assessment diagnostics = discarded %d, retry %t, issues %#v", assessment.DiscardedGroupCount(), assessment.RetryRequired(), assessment.Issues())
}
reordered := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2},
{CandidateIDs: []int{4, 5}, CanonicalCandidateID: 5},
}})
if got := snapshotPlan(reordered.Plan()); !reflect.DeepEqual(got, want) {
t.Fatalf("reordered plan = %#v, want %#v", got, want)
}
empty := preparation.Assess(ProposalResponse{})
if len(empty.Plan().Groups()) != 0 || len(empty.Issues()) != 0 || empty.DiscardedGroupCount() != 0 || empty.RetryRequired() {
t.Fatalf("empty assessment = plan %#v, issues %#v, discarded %d, retry %t", empty.Plan().Groups(), empty.Issues(), empty.DiscardedGroupCount(), empty.RetryRequired())
}
}
func TestIssueDetailsPreservesIssueOrder(t *testing.T) {
issues := []Issue{
{GroupIndex: 3, Category: IssueCanonicalUnknown},
{GroupIndex: 1, Category: IssueMemberUnknown},
}
want := []string{"group 3: canonical_unknown", "group 1: member_unknown"}
if got := IssueDetails(issues); !reflect.DeepEqual(got, want) {
t.Fatalf("IssueDetails() = %#v, want %#v", got, want)
}
}
func TestAssessRejectsEveryUnsafeLocalGroupShape(t *testing.T) {
preparation := proposalPreparation(t)
tests := []struct {
name string
group DuplicateGroup
category IssueCategory
}{
{name: "zero member", group: DuplicateGroup{CandidateIDs: []int{0, 2}, CanonicalCandidateID: 2}, category: IssueMemberNonPositive},
{name: "negative member", group: DuplicateGroup{CandidateIDs: []int{-1, 2}, CanonicalCandidateID: 2}, category: IssueMemberNonPositive},
{name: "unknown member", group: DuplicateGroup{CandidateIDs: []int{99, 2}, CanonicalCandidateID: 2}, category: IssueMemberUnknown},
{name: "repeated member", group: DuplicateGroup{CandidateIDs: []int{1, 1}, CanonicalCandidateID: 1}, category: IssueRepeatedMember},
{name: "too small", group: DuplicateGroup{CandidateIDs: []int{1}, CanonicalCandidateID: 1}, category: IssueFewerThanTwoMembers},
{name: "zero canonical", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 0}, category: IssueCanonicalNonPositive},
{name: "negative canonical", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: -1}, category: IssueCanonicalNonPositive},
{name: "unknown canonical", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 99}, category: IssueCanonicalUnknown},
{name: "canonical not member", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 3}, category: IssueCanonicalNotMember},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assessment := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{test.group}})
if len(assessment.Plan().Groups()) != 0 || assessment.DiscardedGroupCount() != 1 || !assessment.RetryRequired() {
t.Fatalf("assessment = plan %#v, discarded %d, retry %t", assessment.Plan().Groups(), assessment.DiscardedGroupCount(), assessment.RetryRequired())
}
if !hasIssue(assessment.Issues(), 0, test.category) {
t.Fatalf("issues = %#v, want category %q", assessment.Issues(), test.category)
}
})
}
}
func TestAssessDiscardsEveryOverlappingGroupAndRetainsIndependentGroups(t *testing.T) {
preparation := proposalPreparation(t)
assessment := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 1},
{CandidateIDs: []int{2, 3}, CanonicalCandidateID: 2},
{CandidateIDs: []int{4, 5}, CanonicalCandidateID: 5},
}})
wantPlan := []planGroupSnapshot{{members: []int{5, 6}, canonical: 6}}
if got := snapshotPlan(assessment.Plan()); !reflect.DeepEqual(got, wantPlan) {
t.Fatalf("plan = %#v, want %#v", got, wantPlan)
}
if assessment.DiscardedGroupCount() != 2 || !assessment.RetryRequired() {
t.Fatalf("discarded = %d, retry = %t", assessment.DiscardedGroupCount(), assessment.RetryRequired())
}
issues := assessment.Issues()
if len(issues) != 2 || !hasIssue(issues, 0, IssueOverlappingMember) || !hasIssue(issues, 1, IssueOverlappingMember) {
t.Fatalf("issues = %#v, want both conflicting group indexes", issues)
}
invalidAndSafe := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
{CandidateIDs: []int{1, 99}, CanonicalCandidateID: 1},
{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2},
}})
wantPlan = []planGroupSnapshot{{members: []int{0, 2}, canonical: 2}}
if got := snapshotPlan(invalidAndSafe.Plan()); !reflect.DeepEqual(got, wantPlan) {
t.Fatalf("plan with independent invalid group = %#v, want %#v", got, wantPlan)
}
if invalidAndSafe.DiscardedGroupCount() != 1 || !invalidAndSafe.RetryRequired() || !hasIssue(invalidAndSafe.Issues(), 0, IssueMemberUnknown) {
t.Fatalf("invalid-and-safe assessment = discarded %d, retry %t, issues %#v", invalidAndSafe.DiscardedGroupCount(), invalidAndSafe.RetryRequired(), invalidAndSafe.Issues())
}
}
func TestAssessmentAccessorsAndInputsDoNotShareRetainedState(t *testing.T) {
preparation := proposalPreparation(t)
response := ProposalResponse{DuplicateGroups: []DuplicateGroup{
{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2},
}}
assessment := preparation.Assess(response)
want := snapshotPlan(assessment.Plan())
response.DuplicateGroups[0].CandidateIDs[0] = 99
response.DuplicateGroups[0].CanonicalCandidateID = 99
plan := assessment.Plan()
groups := plan.Groups()
members := groups[0].MemberPositions()
members[0] = 99
groups[0] = PlanGroup{}
if got := snapshotPlan(assessment.Plan()); !reflect.DeepEqual(got, want) {
t.Fatalf("assessment plan changed through returned or input data: %#v", got)
}
invalid := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{
CandidateIDs: []int{1, 99}, CanonicalCandidateID: 1,
}}})
issues := invalid.Issues()
issues[0].GroupIndex = 99
issues[0].Category = IssueOverlappingMember
if retained := invalid.Issues(); retained[0].GroupIndex == 99 || retained[0].Category == IssueOverlappingMember {
t.Fatalf("Issues() exposed retained state: %#v", retained)
}
}
type planGroupSnapshot struct {
members []int
canonical int
}
func snapshotPlan(plan Plan) []planGroupSnapshot {
groups := plan.Groups()
snapshot := make([]planGroupSnapshot, len(groups))
for index, group := range groups {
snapshot[index] = planGroupSnapshot{
members: group.MemberPositions(),
canonical: group.CanonicalPosition(),
}
}
return snapshot
}
func hasIssue(issues []Issue, groupIndex int, category IssueCategory) bool {
for _, issue := range issues {
if issue.GroupIndex == groupIndex && issue.Category == category {
return true
}
}
return false
}
func proposalPreparation(t *testing.T) Preparation {
t.Helper()
document := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, 7)}
candidates := make([]Candidate, len(document.Units))
for position := range document.Units {
unitID := position + 1
document.Units[position] = source.SourceUnit{ID: unitID, Text: "unit"}
candidates[position] = Candidate{
Label: "candidate",
SourceRefs: []source.SourceRef{{
SourceID: document.ID,
StartUnitID: unitID,
EndUnitID: unitID,
}},
}
}
for _, position := range []int{1, 4} {
candidates[position].SourceRefs[0].SourceID = "other"
}
preparation, err := Prepare(document, candidates, Limits{
ContextRadius: 0,
MaximumCandidates: len(candidates),
MaximumMaterialBytes: 10000,
})
if err != nil || preparation.Disposition() != Ready {
t.Fatalf("Prepare() disposition = %v, error = %v", preparation.Disposition(), err)
}
return preparation
}

View File

@@ -0,0 +1,41 @@
package semanticreconcile
import (
"fmt"
"io/fs"
rootassets "gitea.maximumdirect.net/eric/notarius/assets"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
)
const (
ResponseSchemaKey = llm.ResponseSchemaKey("semantic_reconciliation_llm")
ResponseSchemaID = "notarius.generic.semantic_reconciliation.llm"
ResponseSchemaName = "notarius_semantic_reconciliation_llm_v1"
SchemaVersion = "v1"
SchemaAssetPath = "schemas/semantic_reconciliation_llm.v1.json"
)
func assetFS() (fs.FS, error) {
assets, err := fs.Sub(rootassets.FS(), "generic/normalize/deduplication")
if err != nil {
return nil, fmt.Errorf("scope semantic reconciliation assets: %w", err)
}
return assets, nil
}
// LoadResponseSchema returns the private request-local integer proposal
// contract. It is separate from every durable artifact schema.
func LoadResponseSchema() (llm.ResponseSchema, error) {
assets, err := assetFS()
if err != nil {
return llm.ResponseSchema{}, err
}
return llm.LoadResponseSchema(assets, llm.ResponseSchemaDefinition{
Key: ResponseSchemaKey,
ID: ResponseSchemaID,
Version: SchemaVersion,
Name: ResponseSchemaName,
AssetPath: SchemaAssetPath,
})
}

View File

@@ -0,0 +1,107 @@
package semanticreconcile
import (
"bytes"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/santhosh-tekuri/jsonschema/v6"
)
func TestResponseSchemaMetadataAndOwnership(t *testing.T) {
schema, err := LoadResponseSchema()
if err != nil {
t.Fatal(err)
}
if schema.Key != ResponseSchemaKey || schema.ID != ResponseSchemaID || schema.Version != SchemaVersion || schema.Name != ResponseSchemaName {
t.Fatalf("schema metadata = %#v", schema)
}
if !json.Valid(schema.JSONSchema) || !strings.HasPrefix(schema.SHA256, "sha256:") {
t.Fatalf("schema content metadata = %#v", schema)
}
first := append([]byte(nil), schema.JSONSchema...)
schema.JSONSchema[0] = '['
loadedAgain, err := LoadResponseSchema()
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(loadedAgain.JSONSchema, first) || !json.Valid(loadedAgain.JSONSchema) {
t.Fatal("LoadResponseSchema() exposed shared schema content")
}
}
func TestResponseSchemaAcceptsOnlyTheIntegerProposalShape(t *testing.T) {
schema, err := LoadResponseSchema()
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
value any
valid bool
}{
{name: "empty proposal", value: map[string]any{"duplicate_groups": []any{}}, valid: true},
{name: "valid group", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 1}}}, valid: true},
{name: "semantic canonical mismatch", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 3}}}, valid: true},
{name: "missing proposal", value: map[string]any{}, valid: false},
{name: "unknown top-level field", value: map[string]any{"duplicate_groups": []any{}, "extra": true}, valid: false},
{name: "missing members", value: map[string]any{"duplicate_groups": []any{map[string]any{"canonical_candidate_id": 1}}}, valid: false},
{name: "missing canonical", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}}}}, valid: false},
{name: "unknown group field", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 1, "name": "replacement"}}}, valid: false},
{name: "too few members", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1}, "canonical_candidate_id": 1}}}, valid: false},
{name: "repeated members", 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_ids": []any{0, 1}, "canonical_candidate_id": 1}}}, valid: false},
{name: "negative member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{-1, 1}, "canonical_candidate_id": 1}}}, valid: false},
{name: "non-integer member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2.5}, "canonical_candidate_id": 1}}}, valid: false},
{name: "zero canonical", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 0}}}, valid: false},
{name: "contextual selectors", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 1, "source_refs": []any{}}}}, valid: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
content, err := json.Marshal(test.value)
if err != nil {
t.Fatal(err)
}
err = validateAgainstSchema(content, schema.JSONSchema)
if (err == nil) != test.valid {
t.Fatalf("schema validation error = %v, want valid=%t", err, test.valid)
}
})
}
content := []byte(`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`)
if err := validateAgainstSchema(content, schema.JSONSchema); err != nil {
t.Fatal(err)
}
var response ProposalResponse
if err := json.Unmarshal(content, &response); err != nil {
t.Fatal(err)
}
want := ProposalResponse{DuplicateGroups: []DuplicateGroup{{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2}}}
if !reflect.DeepEqual(response, want) {
t.Fatalf("decoded response = %#v, want %#v", response, want)
}
}
func validateAgainstSchema(instanceContent, schemaContent []byte) error {
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(instanceContent))
if err != nil {
return err
}
document, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaContent))
if err != nil {
return err
}
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("schema.json", document); err != nil {
return err
}
compiled, err := compiler.Compile("schema.json")
if err != nil {
return err
}
return compiled.Validate(instance)
}

View File

@@ -48,13 +48,15 @@ func TestPromptAssetsPrepareItemOccurrencePrompt(t *testing.T) {
rendered[index] = message.Content
}
content := strings.Join(rendered, "\n")
for _, field := range []string{"start_unit_id", "end_unit_id", "source_id"} {
for _, field := range []string{"start_unit_id", "end_unit_id"} {
if !strings.Contains(content, field) {
t.Fatalf("prepared prompt does not include shared evidence field %q", field)
}
}
if strings.Contains(content, "start_segment") || strings.Contains(content, "end_segment") {
t.Fatalf("prepared prompt contains obsolete segment evidence fields: %s", content)
for _, obsolete := range []string{"source_id", "start_segment", "end_segment"} {
if strings.Contains(content, obsolete) {
t.Fatalf("prepared prompt contains obsolete evidence field %q: %s", obsolete, content)
}
}
}

View File

@@ -3,7 +3,6 @@ package itemregistry
import (
"context"
"errors"
"fmt"
"reflect"
"sort"
@@ -13,19 +12,18 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
)
const (
Key = "dnd/item-registry"
PromptID = "dnd.item_registry.normalize"
normalizationPolicy = "dnd.item_registry.normalize.v2"
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
semanticContextRadius = 2
PromptVersion = "v1"
normalizationPolicy = "dnd.item_registry.normalize.v3"
NormalizationPolicy = normalizationPolicy
ReasonCodeItemFieldsNormalized = "item_fields_normalized"
@@ -47,9 +45,7 @@ var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
type Options struct{}
type Normalizer struct {
llm contracts.StructuredLLMClient
promptSHA string
responseSchemaSHA string
engine *semanticreconcile.Engine
}
func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error) {
@@ -60,46 +56,45 @@ func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error
if err != nil {
return nil, normalizerErrorf("load prompt metadata: %w", err)
}
responseSchema, err := entityreconcile.LoadResponseSchema()
engine, err := semanticreconcile.NewEngine(llmClient, semanticreconcile.PromptSpec{
ID: PromptID, Version: PromptVersion, SHA256: promptSHA,
}, semanticreconcile.DefaultLimits())
if err != nil {
return nil, normalizerErrorf("load response schema: %w", err)
return nil, normalizerErrorf("construct semantic reconciliation engine: %w", err)
}
return &Normalizer{llm: llmClient, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256}, nil
return &Normalizer{engine: engine}, nil
}
func (n *Normalizer) Key() string { return Key }
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (n *Normalizer) ManifestMetadata() map[string]any {
if n == nil {
if n == nil || n.engine == nil {
return nil
}
return map[string]any{
"prompt_id": PromptID, "prompt_version": entityreconcile.SchemaVersion, "prompt_sha256": n.promptSHA,
"response_schema_key": string(entityreconcile.ResponseSchemaKey), "response_schema_id": entityreconcile.ResponseSchemaID,
"response_schema_name": entityreconcile.ResponseSchemaName, "response_schema_version": entityreconcile.SchemaVersion,
"response_schema_sha256": n.responseSchemaSHA, "identity_policy": identity.Policy,
"normalization_policy": normalizationPolicy, "semantic_context_policy": semanticContextPolicy, "semantic_context_radius": semanticContextRadius,
}
metadata := n.engine.ManifestMetadata()
metadata["identity_policy"] = identity.Policy
metadata["normalization_policy"] = normalizationPolicy
return metadata
}
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if n == nil {
if n == nil || n.engine == nil {
return nil
}
return []pipeline.CheckpointFingerprint{
{Name: "prompt", Value: n.promptSHA}, {Name: "response_schema", Value: n.responseSchemaSHA},
{Name: "identity_policy", Value: identity.Policy}, {Name: "normalization_policy", Value: normalizationPolicy},
{Name: "semantic_context_policy", Value: fmt.Sprintf("%s:%d", semanticContextPolicy, semanticContextRadius)},
}
fingerprints := n.engine.CheckpointFingerprints()
return append(fingerprints,
pipeline.CheckpointFingerprint{Name: "identity_policy", Value: identity.Policy},
pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy},
)
}
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.ItemRegistry]) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) {
if n == nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("normalizer must not be nil")
}
if n.llm == nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("LLM client must not be nil")
if n.engine == nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("semantic reconciliation engine must not be nil")
}
if ctx == nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("context must not be nil")
@@ -107,39 +102,51 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
if err := ctx.Err(); err != nil {
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)
records, warnings := preprocessRecords(req.MergeOutput.Value, order)
deterministic := recordList(records)
materials, ready, err := entityreconcile.BuildContext(req.Source, reconciliationCandidates(records), semanticContextRadius)
if err != nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("build semantic context: %w", err)
}
if !ready {
if len(records) < 2 {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
}
var response entityreconcile.ProposalResponse
if _, err := n.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key, PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion,
candidates, envelopes, err := reconciliationInputs(records)
if err != nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("prepare semantic reconciliation inputs: %w", err)
}
reconciliation, err := n.engine.Reconcile(ctx, semanticreconcile.Request{
StageName: Key, Source: req.Source, Candidates: candidates,
ProfileID: req.LLMProfile, SessionID: req.SessionID,
Inputs: contracts.LLMInputSet{"candidates": materials.Candidates, "transcript": materials.Transcript},
}, &response); err != nil {
if errors.Is(err, contracts.ErrInvalidStructuredOutput) {
})
if err != nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("reconcile semantic duplicates: %w", err)
}
switch reconciliation.Disposition() {
case semanticreconcile.SkippedInsufficientCandidates:
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
case semanticreconcile.SkippedLimitExceeded:
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: deterministic, Warnings: limitWarningsWithSemanticFallback(warnings)}, nil
case semanticreconcile.RetryableInvalidStructuredOutput:
return n.invalidStructuredResult(deterministic, warnings), nil
case semanticreconcile.Complete, semanticreconcile.RetryableDiscardedProposalGroups:
default:
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("unknown semantic reconciliation disposition %d", reconciliation.Disposition())
}
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("complete structured output: %w", err)
applied, semanticWarnings, rejectedGroups, err := applyReconciliationPlan(reconciliation.Plan(), records, envelopes, order)
if err != nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("apply semantic reconciliation plan: %w", err)
}
assessment := materials.Assess(response)
applied, semanticWarnings, rejectedGroups := applySafeGroups(records, reconciliationGroups(assessment, materials.CandidateKeys()), order)
warnings = append(warnings, semanticWarnings...)
if rejectedGroups > 0 {
return currencyRetryResult(recordList(applied), warnings, rejectedGroups), nil
}
if assessment.DiscardedGroups() == 0 {
discardedGroups := reconciliation.DiscardedGroupCount() + rejectedGroups
if discardedGroups == 0 {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: recordList(applied), Warnings: limitWarnings(warnings)}, nil
}
return retryResult(recordList(applied), warnings, assessment), nil
return retryResult(recordList(applied), warnings, reconciliation, rejectedGroups), nil
}
func (n *Normalizer) invalidStructuredResult(value dnd.ItemRegistry, warnings []contracts.Warning) contracts.TypedNormalizeResult[dnd.ItemRegistry] {
@@ -149,17 +156,15 @@ func (n *Normalizer) invalidStructuredResult(value dnd.ItemRegistry, warnings []
}}
}
func retryResult(value dnd.ItemRegistry, warnings []contracts.Warning, assessment entityreconcile.Assessment) contracts.TypedNormalizeResult[dnd.ItemRegistry] {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: value, Warnings: limitWarningsForRetry(warnings), Retry: &contracts.NormalizeRetry{
ReasonCode: ReasonCodeItemSemanticProposalInvalid, Message: diagnostics.Aggregate("semantic proposal requires retry", reconciliationIssues(assessment)),
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(assessment.DiscardedGroups())},
}}
func retryResult(value dnd.ItemRegistry, warnings []contracts.Warning, reconciliation semanticreconcile.Result, rejectedGroups int) contracts.TypedNormalizeResult[dnd.ItemRegistry] {
details := semanticreconcile.IssueDetails(reconciliation.Issues())
if rejectedGroups > 0 {
details = append(details, "currency may only be consolidated with aliases of one denomination")
}
func currencyRetryResult(value dnd.ItemRegistry, warnings []contracts.Warning, rejectedGroups int) contracts.TypedNormalizeResult[dnd.ItemRegistry] {
discardedGroups := reconciliation.DiscardedGroupCount() + rejectedGroups
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: value, Warnings: limitWarningsForRetry(warnings), Retry: &contracts.NormalizeRetry{
ReasonCode: ReasonCodeItemSemanticProposalInvalid, Message: "semantic proposal requires retry: currency may only be consolidated with aliases of one denomination",
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(rejectedGroups)},
ReasonCode: ReasonCodeItemSemanticProposalInvalid, Message: diagnostics.Aggregate("semantic proposal requires retry", details),
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(discardedGroups)},
}}
}
@@ -187,6 +192,10 @@ func limitWarningsForRetry(warnings []contracts.Warning) []contracts.Warning {
return append(bounded, contracts.Warning{Scope: "items", ReasonCode: ReasonCodeItemNormalizationWarningsOmitted, Message: fmt.Sprintf("%d additional warning(s) omitted", len(warnings)-displayed)})
}
func limitWarningsWithSemanticFallback(warnings []contracts.Warning) []contracts.Warning {
return append(limitWarningsForRetry(warnings), semanticFallbackWarning(-1))
}
type normalizedRecord struct {
item dnd.Item
inputIndexes []int

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strconv"
"strings"
@@ -14,10 +15,10 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
identityvalidator "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemregistry/identity"
"gitea.maximumdirect.net/eric/promptkit"
)
@@ -35,9 +36,25 @@ func TestModuleContractAndMetadata(t *testing.T) {
t.Fatal("New() accepted nil client")
}
metadata := newNormalizer(t, &recordingNormalizerClient{}).ManifestMetadata()
if metadata["identity_policy"] != identity.Policy || metadata["response_schema_id"] != entityreconcile.ResponseSchemaID || metadata["normalization_policy"] != normalizationPolicy || metadata["semantic_context_radius"] != semanticContextRadius {
limits, ok := metadata["semantic_reconciliation_limits"].(map[string]any)
if !ok || metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy || metadata["prompt_id"] != PromptID || metadata["prompt_version"] != PromptVersion || metadata["response_schema_key"] != string(semanticreconcile.ResponseSchemaKey) || metadata["response_schema_id"] != semanticreconcile.ResponseSchemaID || metadata["response_schema_name"] != semanticreconcile.ResponseSchemaName || metadata["semantic_reconciliation_policy"] != semanticreconcile.Policy || len(limits) != 3 {
t.Fatalf("metadata = %#v", metadata)
}
for _, name := range []string{"prompt", "response_schema", "semantic_reconciliation_policy", "semantic_reconciliation_limits", "identity_policy", "normalization_policy"} {
if !hasFingerprint(newNormalizer(t, &recordingNormalizerClient{}).CheckpointFingerprints(), name) {
t.Fatalf("fingerprints missing %q", name)
}
}
}
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) {
@@ -73,10 +90,10 @@ func TestNormalizeConsolidatesEqualNamesAcrossEvidenceWithoutMutation(t *testing
}
var candidates struct {
Candidates []struct {
Name string `json:"name"`
Label string `json:"label"`
} `json:"candidates"`
}
if err := json.Unmarshal(client.requests[0].Inputs["candidates"].Content, &candidates); err != nil || len(candidates.Candidates) != 2 || candidates.Candidates[0].Name != "Rope" || candidates.Candidates[1].Name != "Lantern" {
if err := json.Unmarshal(client.requests[0].Inputs["candidates"].Content, &candidates); err != nil || len(candidates.Candidates) != 2 || candidates.Candidates[0].Label != "Rope" || candidates.Candidates[1].Label != "Lantern" {
t.Fatalf("semantic candidates = %#v, %v; want one candidate per comparison name", candidates, err)
}
repeated, repeatErr := newNormalizer(t, &recordingNormalizerClient{}).Normalize(context.Background(), normalizeRequestWithSource(result.Value, doc))
@@ -106,7 +123,7 @@ func TestNormalizeAppliesSafeAliasProposal(t *testing.T) {
{Name: "Compass of the Stars", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
{Name: "Gold Pieces", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}}
client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`}
client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`}
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry != nil || len(result.Value.Items) != 2 {
t.Fatalf("Normalize() = %#v, %v", result, err)
@@ -142,7 +159,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Piece", SourceRefs: ref(20)},
{Name: "Gold Pieces", SourceRefs: ref(30)},
},
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002","candidate-000003"],"canonical":"candidate-000002"}]}`,
response: `{"duplicate_groups":[{"candidate_ids":[1,2,3],"canonical_candidate_id":2}]}`,
wantNames: []string{"Gold Piece"},
wantRefCounts: []int{3},
warning: ReasonCodeDuplicateItemCollapsed,
@@ -153,7 +170,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Pieces", SourceRefs: ref(10)},
{Name: "Silver Pieces", SourceRefs: ref(20)},
},
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`,
response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1}]}`,
wantNames: []string{"Gold Pieces", "Silver Pieces"},
wantRefCounts: []int{1, 1},
wantRetry: true,
@@ -165,7 +182,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Pieces", SourceRefs: ref(10)},
{Name: "Longsword", SourceRefs: ref(20)},
},
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`,
response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1}]}`,
wantNames: []string{"Gold Pieces", "Longsword"},
wantRefCounts: []int{1, 1},
wantRetry: true,
@@ -177,7 +194,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Star Compass", SourceRefs: ref(10)},
{Name: "Compass of the Stars", SourceRefs: ref(20)},
},
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`,
response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`,
wantNames: []string{"Compass of the Stars"},
wantRefCounts: []int{2},
warning: ReasonCodeDuplicateItemCollapsed,
@@ -188,7 +205,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Pieces", SourceRefs: ref(10)},
{Name: "Longsword", SourceRefs: ref(20)},
},
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`,
response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`,
wantNames: []string{"Gold Pieces", "Longsword"},
wantRefCounts: []int{1, 1},
wantRetry: true,
@@ -231,13 +248,67 @@ func TestNormalizePreservesCandidatesForUnsafeProposalGroups(t *testing.T) {
{Name: "Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
{Name: "Rope", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}}
client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"},{"members":["candidate-000002","candidate-000003"],"canonical":"candidate-000003"},{"members":["candidate-000001","candidate-000003"],"canonical":"candidate-000099"}]}`}
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}]}`}
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry == nil || len(result.Value.Items) != 3 || !strings.Contains(result.Retry.Message, "overlapping_member") || !strings.Contains(result.Retry.Message, "canonical_unknown") || strings.Contains(result.Retry.Message, "Star Compass") || len(result.Retry.Message) > 4096 {
t.Fatalf("Normalize() = %#v, %v; want deterministic retry fallback", result, err)
}
}
func TestNormalizeAppliesIndependentGroupAndCountsAllOmissions(t *testing.T) {
doc := semanticDocument()
ref := func(unitID int) []source.SourceRef {
return []source.SourceRef{{SourceID: doc.ID, StartUnitID: unitID, EndUnitID: unitID}}
}
input := dnd.ItemRegistry{Items: []dnd.Item{
{Name: "Star Compass", SourceRefs: ref(10)},
{Name: "Compass of the Stars", SourceRefs: ref(20)},
{Name: "Gold Pieces", SourceRefs: ref(30)},
{Name: "Silver Pieces", SourceRefs: ref(30)},
{Name: "Rope", SourceRefs: ref(10)},
}}
client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2},{"candidate_ids":[3,4],"canonical_candidate_id":3},{"candidate_ids":[5,99],"canonical_candidate_id":5}]}`}
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry == nil {
t.Fatalf("Normalize() = %#v, %v; want retry with independently accepted output", result, err)
}
wantNames := []string{"Compass of the Stars", "Gold Pieces", "Silver Pieces", "Rope"}
if len(result.Value.Items) != len(wantNames) {
t.Fatalf("items = %#v, want %v", result.Value.Items, wantNames)
}
for index, name := range wantNames {
if result.Value.Items[index].Name != name {
t.Fatalf("item %d = %#v, want %q", index, result.Value.Items[index], name)
}
}
if !hasWarning(result.Warnings, ReasonCodeDuplicateItemCollapsed) || !hasWarning(result.Warnings, ReasonCodeItemSemanticProposalInvalid) {
t.Fatalf("warnings = %#v, want accepted and guarded-group diagnostics", result.Warnings)
}
if len(result.Retry.FallbackWarnings) != 1 || !strings.Contains(result.Retry.FallbackWarnings[0].Message, "2 proposal group(s)") {
t.Fatalf("retry = %#v, want one guarded and one malformed group counted", result.Retry)
}
}
func TestNormalizeLimitSkipDoesNotCallLLMAndAddsBoundedFallbackWarning(t *testing.T) {
client := &recordingNormalizerClient{}
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Kind: "narration", Text: "A crowded storeroom"}}}
limit := semanticreconcile.DefaultLimits().MaximumCandidates
input := dnd.ItemRegistry{Items: make([]dnd.Item, limit+1)}
for index := range input.Items {
input.Items[index] = dnd.Item{Name: fmt.Sprintf("Item %d", index), SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}
}
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry != nil {
t.Fatalf("Normalize() = %#v, %v; want deterministic limit fallback", result, err)
}
if len(client.requests) != 0 || len(result.Value.Items) != limit+1 {
t.Fatalf("completion calls = %d, items = %d; want no call and all records", len(client.requests), len(result.Value.Items))
}
if !hasWarning(result.Warnings, ReasonCodeItemSemanticReconciliationExhausted) || len(result.Warnings) > diagnostics.MaxWarnings {
t.Fatalf("warnings = %#v, want bounded reconciliation fallback", result.Warnings)
}
}
func TestNormalizeRetryFallbackErrorsWarningsAndIdempotence(t *testing.T) {
doc := semanticDocument()
input := dnd.ItemRegistry{Items: []dnd.Item{{Name: "Star Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {Name: "Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}}}
@@ -261,7 +332,7 @@ func TestNormalizeRetryFallbackErrorsWarningsAndIdempotence(t *testing.T) {
func TestRegisterPromptAssetsPreparesItemNormalizationPrompt(t *testing.T) {
registry := llm.NewAssetRegistry()
if err := entityreconcile.RegisterSchemaAssets(registry); err != nil {
if err := semanticreconcile.RegisterAssets(registry); err != nil {
t.Fatal(err)
}
if err := RegisterPromptAssets(registry); err != nil {
@@ -276,13 +347,16 @@ func TestRegisterPromptAssetsPreparesItemNormalizationPrompt(t *testing.T) {
if err != nil {
t.Fatal(err)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "item-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"name":"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(`{"candidates":[{"candidate_id":1,"label":"Rope","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
if err != nil {
t.Fatal(err)
}
if prepared.OutputContract.SchemaPath != "dnd_entity_reconcile_llm.v1.json" || !strings.Contains(prepared.Messages[1].Content, "currency denominations") || !strings.Contains(prepared.Messages[1].Content, "materially different item types") {
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 {
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"`) {
t.Fatalf("prepared prompt = %#v, want isolated candidate and transcript presentation", prepared)
}
}
type recordingNormalizerClient struct {
@@ -300,53 +374,13 @@ func (c *recordingNormalizerClient) CompleteStructured(_ context.Context, reques
if response == "" {
response = `{"duplicate_groups":[]}`
}
content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
content := []byte(response)
if err := json.Unmarshal(content, output); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) {
if !strings.Contains(response, "candidate-") {
return []byte(response), nil
}
var selection struct {
DuplicateGroups []struct {
Members []string `json:"members"`
Canonical string `json:"canonical"`
} `json:"duplicate_groups"`
}
if err := json.Unmarshal([]byte(response), &selection); err != nil {
return nil, err
}
var candidates struct {
Candidates []entityreconcile.Selector `json:"candidates"`
}
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
return nil, err
}
selector := func(key string) entityreconcile.Selector {
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
if err != nil || index < 1 || index > len(candidates.Candidates) {
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
}
return candidates.Candidates[index-1].Clone()
}
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
for index, group := range selection.DuplicateGroups {
members := make([]entityreconcile.Selector, len(group.Members))
for memberIndex, key := range group.Members {
members[memberIndex] = selector(key)
}
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
}
return json.Marshal(proposal)
}
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {
t.Helper()
normalizer, err := New(client, Options{})
@@ -356,7 +390,10 @@ func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normaliz
return normalizer
}
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] {
request := normalizeRequest(value)
@@ -374,3 +411,12 @@ func hasWarning(warnings []contracts.Warning, reason string) bool {
}
return false
}
func hasFingerprint(fingerprints []pipeline.CheckpointFingerprint, name string) bool {
for _, fingerprint := range fingerprints {
if fingerprint.Name == name && fingerprint.Value != "" {
return true
}
}
return false
}

View File

@@ -8,19 +8,26 @@ import (
rootassets "gitea.maximumdirect.net/eric/notarius/assets"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const promptAssetRoot = "assets/prompts"
var promptAssetManifest = shared.PromptAssetManifest{
func promptAssetManifest() (shared.PromptAssetManifest, error) {
sharedFiles, err := semanticreconcile.SharedPromptFiles()
if err != nil {
return shared.PromptAssetManifest{}, fmt.Errorf("load shared semantic reconciliation prompt assets: %w", err)
}
return shared.PromptAssetManifest{
ModuleDir: PromptID,
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
{Name: "instructions.md", Path: "prompts/instructions.md"},
{Name: "candidates.md", Path: "prompts/candidates.md"},
},
SharedFiles: []string{"common-dnd-system.md", "common-dnd-entity-reconciliation.md", "common-dnd-transcript-windows.md"},
SharedFiles: []string{"common-dnd-system.md"},
ExternalSharedFiles: sharedFiles,
}, nil
}
func moduleAssetFS() (fs.FS, error) {
@@ -36,7 +43,11 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
if err != nil {
return err
}
promptFS, err := promptAssetManifest.PromptFS(assets)
manifest, err := promptAssetManifest()
if err != nil {
return err
}
promptFS, err := manifest.PromptFS(assets)
if err != nil {
return fmt.Errorf("prepare item normalization prompt assets: %w", err)
}
@@ -50,7 +61,12 @@ func promptAssetMetadata() (string, error) {
promptAssetHashErr = err
return
}
promptAssetHash, promptAssetHashErr = promptAssetManifest.Hash(assets)
manifest, err := promptAssetManifest()
if err != nil {
promptAssetHashErr = err
return
}
promptAssetHash, promptAssetHashErr = manifest.Hash(assets)
})
return promptAssetHash, promptAssetHashErr
}

View File

@@ -2,104 +2,106 @@ package itemregistry
import (
"fmt"
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
)
type safeReconciliationGroup struct {
members []int
canonical int
}
const incompatibleCurrencyGroup semanticreconcile.RejectionCategory = "incompatible_currency"
func reconciliationCandidates(records []normalizedRecord) []entityreconcile.Candidate {
candidates := make([]entityreconcile.Candidate, len(records))
func reconciliationInputs(records []normalizedRecord) ([]semanticreconcile.Candidate, []semanticreconcile.Record[dnd.Item], error) {
candidates := make([]semanticreconcile.Candidate, len(records))
envelopes := make([]semanticreconcile.Record[dnd.Item], len(records))
for index, record := range records {
candidates[index] = entityreconcile.Candidate{Name: record.item.Name, SourceRefs: cloneSourceRefs(record.item.SourceRefs)}
candidates[index] = semanticreconcile.Candidate{
Label: record.item.Name,
SourceRefs: cloneSourceRefs(record.item.SourceRefs),
}
return candidates
envelope, err := semanticreconcile.NewRecord(record.item, record.inputIndexes, record.earliest, cloneItem)
if err != nil {
return nil, nil, fmt.Errorf("record %d: %w", index, err)
}
envelopes[index] = envelope
}
return candidates, envelopes, nil
}
func reconciliationGroups(assessment entityreconcile.Assessment, candidateKeys []string) []safeReconciliationGroup {
positions := make(map[string]int, len(candidateKeys))
for index, key := range candidateKeys {
positions[key] = index
func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRecord, envelopes []semanticreconcile.Record[dnd.Item], order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning, int, error) {
application, err := semanticreconcile.ApplyPlan(plan, envelopes, semanticreconcile.ApplicationPolicy[dnd.Item]{
CloneValue: cloneItem,
RejectGroup: func(members []dnd.Item, _ dnd.Item) semanticreconcile.RejectionCategory {
if !canConsolidate(members) {
return incompatibleCurrencyGroup
}
safeGroups := assessment.SafeGroups()
groups := make([]safeReconciliationGroup, 0, len(safeGroups))
for _, group := range safeGroups {
members := group.Members()
memberPositions := make([]int, len(members))
valid := true
for index, key := range members {
position, ok := positions[key]
if !ok {
valid = false
break
return ""
},
ConsolidateGroup: func(members []dnd.Item, canonical dnd.Item) (dnd.Item, error) {
output := cloneItem(canonical)
output.SourceRefs = nil
for _, member := range members {
output.SourceRefs = append(output.SourceRefs, member.SourceRefs...)
}
memberPositions[index] = position
}
canonical, ok := positions[group.Canonical()]
if valid && ok {
groups = append(groups, safeReconciliationGroup{members: memberPositions, canonical: canonical})
}
}
return groups
output.SourceRefs = order.Canonicalize(output.SourceRefs)
output.ID = identity.DeriveID(output.Name)
return output, nil
},
})
if err != nil {
return nil, nil, 0, err
}
func reconciliationIssues(assessment entityreconcile.Assessment) []string {
issues := assessment.Issues()
details := make([]string, len(issues))
for index, issue := range issues {
details[index] = fmt.Sprintf("group %d: %s", issue.GroupIndex, issue.Category)
applied := application.Records()
output := make([]normalizedRecord, len(applied))
for index, record := range applied {
output[index] = normalizedRecord{
item: record.Value(),
inputIndexes: record.OriginalInputIndexes(),
earliest: record.EarliestInputPosition(),
}
return details
}
type orderedWarning struct {
position int
warning contracts.Warning
}
orderedWarnings := make([]orderedWarning, 0, len(application.AppliedGroups())+len(application.RejectedGroups()))
for _, event := range application.AppliedGroups() {
provenance := event.Provenance()
orderedWarnings = append(orderedWarnings, orderedWarning{
position: provenance.EarliestInputPosition(),
warning: semanticDuplicateWarning(provenance, records[provenance.CanonicalPosition()]),
})
}
for _, event := range application.RejectedGroups() {
provenance := event.Provenance()
orderedWarnings = append(orderedWarnings, orderedWarning{
position: provenance.EarliestInputPosition(),
warning: contracts.Warning{
Scope: itemScope(provenance.EarliestInputPosition()),
ReasonCode: ReasonCodeItemSemanticProposalInvalid,
Message: "proposal group preserved because currency may only be consolidated with aliases of one denomination",
},
})
}
sort.SliceStable(orderedWarnings, func(left, right int) bool { return orderedWarnings[left].position < orderedWarnings[right].position })
warnings := make([]contracts.Warning, len(orderedWarnings))
for index, entry := range orderedWarnings {
warnings[index] = entry.warning
}
return output, warnings, len(application.RejectedGroups()), nil
}
func applySafeGroups(records []normalizedRecord, groups []safeReconciliationGroup, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning, int) {
byMember := make(map[int]safeReconciliationGroup, len(groups)*2)
for _, group := range groups {
for _, member := range group.members {
byMember[member] = group
}
}
output := make([]normalizedRecord, 0, len(records)-len(groups))
warnings := make([]contracts.Warning, 0, len(groups))
rejectedGroups := 0
for index, record := range records {
group, grouped := byMember[index]
if !grouped {
output = append(output, cloneRecord(record))
continue
}
if group.members[0] != index {
continue
}
if !canConsolidate(records, group) {
for _, member := range group.members {
output = append(output, cloneRecord(records[member]))
}
warnings = append(warnings, contracts.Warning{Scope: itemScope(records[group.members[0]].earliest), ReasonCode: ReasonCodeItemSemanticProposalInvalid, Message: "proposal group preserved because currency may only be consolidated with aliases of one denomination"})
rejectedGroups++
continue
}
consolidated := consolidateSemanticGroup(records, group, order)
output = append(output, consolidated)
warnings = append(warnings, semanticDuplicateWarning(consolidated, records[group.canonical]))
}
return output, warnings, rejectedGroups
}
func canConsolidate(records []normalizedRecord, group safeReconciliationGroup) bool {
func canConsolidate(items []dnd.Item) bool {
denomination := ""
hasCurrency := false
hasNonCurrency := false
hasConflictingDenominations := false
for _, member := range group.members {
current := currencyDenomination(records[member].item.Name)
for _, item := range items {
current := currencyDenomination(item.Name)
if current == "" {
hasNonCurrency = true
continue
@@ -131,29 +133,18 @@ func currencyDenomination(name string) string {
}
}
func consolidateSemanticGroup(records []normalizedRecord, group safeReconciliationGroup, order shared.SourceRefOrder) normalizedRecord {
output := cloneRecord(records[group.members[0]])
output.item.Name = records[group.canonical].item.Name
for _, member := range group.members[1:] {
output.item.SourceRefs = append(output.item.SourceRefs, records[member].item.SourceRefs...)
output.inputIndexes = append(output.inputIndexes, records[member].inputIndexes...)
if records[member].earliest < output.earliest {
output.earliest = records[member].earliest
}
}
output.inputIndexes = sortedUniqueIndexes(output.inputIndexes)
output.item.SourceRefs = order.Canonicalize(output.item.SourceRefs)
output.item.ID = identity.DeriveID(output.item.Name)
return output
}
func semanticDuplicateWarning(record normalizedRecord, canonical normalizedRecord) contracts.Warning {
details := make([]string, 0, len(record.inputIndexes)+1)
for _, inputIndex := range record.inputIndexes {
func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) contracts.Warning {
inputIndexes := provenance.OriginalInputIndexes()
details := make([]string, 0, len(inputIndexes)+1)
for _, inputIndex := range inputIndexes {
details = append(details, fmt.Sprintf("input index %d", inputIndex))
}
if canonical.earliest != record.earliest {
if canonical.earliest != provenance.EarliestInputPosition() {
details = append(details, fmt.Sprintf("canonical display name from input index %d", canonical.earliest))
}
return contracts.Warning{Scope: itemScope(record.earliest), ReasonCode: ReasonCodeDuplicateItemCollapsed, Message: diagnostics.Aggregate("semantic duplicate consolidation", details)}
return contracts.Warning{
Scope: itemScope(provenance.EarliestInputPosition()),
ReasonCode: ReasonCodeDuplicateItemCollapsed,
Message: diagnostics.Aggregate("semantic duplicate consolidation", details),
}
}

View File

@@ -4,7 +4,6 @@ package locationregistry
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"sort"
@@ -14,19 +13,18 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
)
const (
Key = "dnd/location-registry"
PromptID = "dnd.location_registry.normalize"
normalizationPolicy = "dnd.location_registry.normalize.v2"
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
semanticContextRadius = 2
PromptVersion = "v1"
normalizationPolicy = "dnd.location_registry.normalize.v3"
NormalizationPolicy = normalizationPolicy
ReasonCodeLocationFieldsNormalized = "location_fields_normalized"
@@ -48,9 +46,7 @@ var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
type Options struct{}
type Normalizer struct {
llm contracts.StructuredLLMClient
promptSHA string
responseSchemaSHA string
engine *semanticreconcile.Engine
}
func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error) {
@@ -61,46 +57,45 @@ func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error
if err != nil {
return nil, normalizerErrorf("load prompt metadata: %w", err)
}
responseSchema, err := entityreconcile.LoadResponseSchema()
engine, err := semanticreconcile.NewEngine(llmClient, semanticreconcile.PromptSpec{
ID: PromptID, Version: PromptVersion, SHA256: promptSHA,
}, semanticreconcile.DefaultLimits())
if err != nil {
return nil, normalizerErrorf("load response schema: %w", err)
return nil, normalizerErrorf("construct semantic reconciliation engine: %w", err)
}
return &Normalizer{llm: llmClient, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256}, nil
return &Normalizer{engine: engine}, nil
}
func (n *Normalizer) Key() string { return Key }
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (n *Normalizer) ManifestMetadata() map[string]any {
if n == nil {
if n == nil || n.engine == nil {
return nil
}
return map[string]any{
"prompt_id": PromptID, "prompt_version": entityreconcile.SchemaVersion, "prompt_sha256": n.promptSHA,
"response_schema_key": string(entityreconcile.ResponseSchemaKey), "response_schema_id": entityreconcile.ResponseSchemaID,
"response_schema_name": entityreconcile.ResponseSchemaName, "response_schema_version": entityreconcile.SchemaVersion,
"response_schema_sha256": n.responseSchemaSHA, "identity_policy": identity.Policy,
"normalization_policy": normalizationPolicy, "semantic_context_policy": semanticContextPolicy, "semantic_context_radius": semanticContextRadius,
}
metadata := n.engine.ManifestMetadata()
metadata["identity_policy"] = identity.Policy
metadata["normalization_policy"] = normalizationPolicy
return metadata
}
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if n == nil {
if n == nil || n.engine == nil {
return nil
}
return []pipeline.CheckpointFingerprint{
{Name: "prompt", Value: n.promptSHA}, {Name: "response_schema", Value: n.responseSchemaSHA},
{Name: "identity_policy", Value: identity.Policy}, {Name: "normalization_policy", Value: normalizationPolicy},
{Name: "semantic_context_policy", Value: fmt.Sprintf("%s:%d", semanticContextPolicy, semanticContextRadius)},
}
fingerprints := n.engine.CheckpointFingerprints()
return append(fingerprints,
pipeline.CheckpointFingerprint{Name: "identity_policy", Value: identity.Policy},
pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy},
)
}
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.LocationRegistry]) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) {
if n == nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("normalizer must not be nil")
}
if n.llm == nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("LLM client must not be nil")
if n.engine == nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("semantic reconciliation engine must not be nil")
}
if ctx == nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("context must not be nil")
@@ -108,36 +103,50 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
if err := ctx.Err(); err != nil {
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)
records, warnings := preprocessRecords(req.MergeOutput.Value, order)
deterministic := recordList(records)
materials, ready, err := entityreconcile.BuildContext(req.Source, reconciliationCandidates(records), semanticContextRadius)
if err != nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("build semantic context: %w", err)
}
if !ready {
if len(records) < 2 {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
}
var response entityreconcile.ProposalResponse
if _, err := n.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key, PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion,
candidates, envelopes, err := reconciliationInputs(records)
if err != nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("prepare semantic reconciliation inputs: %w", err)
}
reconciliation, err := n.engine.Reconcile(ctx, semanticreconcile.Request{
StageName: Key, Source: req.Source, Candidates: candidates,
ProfileID: req.LLMProfile, SessionID: req.SessionID,
Inputs: contracts.LLMInputSet{"candidates": materials.Candidates, "transcript": materials.Transcript},
}, &response); err != nil {
if errors.Is(err, contracts.ErrInvalidStructuredOutput) {
})
if err != nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("reconcile semantic duplicates: %w", err)
}
switch reconciliation.Disposition() {
case semanticreconcile.SkippedInsufficientCandidates:
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
case semanticreconcile.SkippedLimitExceeded:
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{Value: deterministic, Warnings: limitWarningsWithSemanticFallback(warnings)}, nil
case semanticreconcile.RetryableInvalidStructuredOutput:
return n.invalidStructuredResult(deterministic, warnings), nil
case semanticreconcile.Complete, semanticreconcile.RetryableDiscardedProposalGroups:
default:
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("unknown semantic reconciliation disposition %d", reconciliation.Disposition())
}
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("complete structured output: %w", err)
applied, semanticWarnings, err := applyReconciliationPlan(reconciliation.Plan(), records, envelopes, order)
if err != nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("apply semantic reconciliation plan: %w", err)
}
assessment := materials.Assess(response)
applied, semanticWarnings := applySafeGroups(records, reconciliationGroups(assessment, materials.CandidateKeys()), order)
warnings = append(warnings, semanticWarnings...)
if assessment.DiscardedGroups() == 0 {
if reconciliation.Disposition() == semanticreconcile.Complete {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{Value: recordList(applied), Warnings: limitWarnings(warnings)}, nil
}
return retryResult(recordList(applied), warnings, assessment), nil
return retryResult(recordList(applied), warnings, reconciliation), nil
}
func (n *Normalizer) invalidStructuredResult(value dnd.LocationRegistry, warnings []contracts.Warning) contracts.TypedNormalizeResult[dnd.LocationRegistry] {
@@ -147,10 +156,10 @@ func (n *Normalizer) invalidStructuredResult(value dnd.LocationRegistry, warning
}}
}
func retryResult(value dnd.LocationRegistry, warnings []contracts.Warning, assessment entityreconcile.Assessment) contracts.TypedNormalizeResult[dnd.LocationRegistry] {
func retryResult(value dnd.LocationRegistry, warnings []contracts.Warning, reconciliation semanticreconcile.Result) contracts.TypedNormalizeResult[dnd.LocationRegistry] {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{Value: value, Warnings: limitWarningsForRetry(warnings), Retry: &contracts.NormalizeRetry{
ReasonCode: ReasonCodeLocationSemanticProposalInvalid, Message: diagnostics.Aggregate("semantic proposal requires retry", reconciliationIssues(assessment)),
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(assessment.DiscardedGroups())},
ReasonCode: ReasonCodeLocationSemanticProposalInvalid, Message: diagnostics.Aggregate("semantic proposal requires retry", semanticreconcile.IssueDetails(reconciliation.Issues())),
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(reconciliation.DiscardedGroupCount())},
}}
}
@@ -178,6 +187,10 @@ func limitWarningsForRetry(warnings []contracts.Warning) []contracts.Warning {
return append(bounded, contracts.Warning{Scope: "locations", ReasonCode: ReasonCodeLocationNormalizationWarningsOmitted, Message: fmt.Sprintf("%d additional warning(s) omitted", len(warnings)-displayed)})
}
func limitWarningsWithSemanticFallback(warnings []contracts.Warning) []contracts.Warning {
return append(limitWarningsForRetry(warnings), semanticFallbackWarning(-1))
}
type normalizedRecord struct {
location dnd.Location
inputIndexes []int

View File

@@ -2,7 +2,9 @@ package locationregistry
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strconv"
"strings"
@@ -11,9 +13,10 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
)
func TestModuleContractAndMetadata(t *testing.T) {
@@ -30,11 +33,24 @@ func TestModuleContractAndMetadata(t *testing.T) {
}
normalizer := newNormalizer(t, &recordingLocationNormalizerClient{})
metadata := normalizer.ManifestMetadata()
if metadata["identity_policy"] != identity.Policy || metadata["response_schema_id"] != entityreconcile.ResponseSchemaID || metadata["normalization_policy"] != normalizationPolicy || metadata["semantic_context_radius"] != semanticContextRadius {
limits, ok := metadata["semantic_reconciliation_limits"].(map[string]any)
if !ok || metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy || metadata["prompt_id"] != PromptID || metadata["prompt_version"] != PromptVersion || metadata["response_schema_key"] != string(semanticreconcile.ResponseSchemaKey) || metadata["response_schema_id"] != semanticreconcile.ResponseSchemaID || metadata["response_schema_name"] != semanticreconcile.ResponseSchemaName || metadata["semantic_reconciliation_policy"] != semanticreconcile.Policy || len(limits) != 3 {
t.Fatalf("metadata = %#v", metadata)
}
if got := normalizer.CheckpointFingerprints(); len(got) != 5 || got[2].Value != identity.Policy || got[3].Value != normalizationPolicy || got[4].Value != semanticContextPolicy+":2" {
t.Fatalf("fingerprints = %#v", got)
for _, name := range []string{"prompt", "response_schema", "semantic_reconciliation_policy", "semantic_reconciliation_limits", "identity_policy", "normalization_policy"} {
if !hasFingerprint(normalizer.CheckpointFingerprints(), name) {
t.Fatalf("fingerprints missing %q", name)
}
}
}
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)
}
}
@@ -46,7 +62,8 @@ func TestNormalizePreparesOnlyExactDuplicatesAndRetainsSameNameAndNestedPlaces(t
{Name: "The Tavern Cellar", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}},
}}
before := dnd.LocationRegistry{Locations: append([]dnd.Location(nil), input.Locations...)}
result, err := newNormalizer(t, &recordingLocationNormalizerClient{}).Normalize(context.Background(), normalizeRequest(input))
client := &recordingLocationNormalizerClient{}
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequest(input))
if err != nil || len(result.Value.Locations) != 3 {
t.Fatalf("Normalize() = %#v, %v; want one exact duplicate removed", result, err)
}
@@ -56,7 +73,7 @@ func TestNormalizePreparesOnlyExactDuplicatesAndRetainsSameNameAndNestedPlaces(t
if got := []string{result.Value.Locations[0].Name, result.Value.Locations[1].Name, result.Value.Locations[2].Name}; !reflect.DeepEqual(got, []string{"The Tavern", "The Tavern", "The Tavern Cellar"}) {
t.Fatalf("locations = %#v, want same names and nested place retained", got)
}
if result.Value.Locations[0].ID == result.Value.Locations[1].ID || !hasWarning(result.Warnings, ReasonCodeDuplicateLocationCollapsed) {
if result.Value.Locations[0].ID == result.Value.Locations[1].ID || !hasWarning(result.Warnings, ReasonCodeDuplicateLocationCollapsed) || len(client.requests) != 0 {
t.Fatalf("result = %#v, want evidence-anchored IDs and exact duplicate warning", result)
}
}
@@ -79,7 +96,7 @@ func BenchmarkExactDuplicateGroupsManyDistinct(b *testing.B) {
}
func TestNormalizeAppliesSafeAliasGroupAndUsesContextualInputs(t *testing.T) {
client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`}
client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`}
doc := semanticDocument()
input := dnd.LocationRegistry{Locations: []dnd.Location{
{Name: "Old Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
@@ -107,7 +124,7 @@ func TestNormalizeRejectsUnsafeAndOverlappingGroupsWithoutLosingCandidates(t *te
{Name: "Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
{Name: "Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}}
client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"},{"members":["candidate-000002","candidate-000003"],"canonical":"candidate-000003"}]}`}
client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1},{"candidate_ids":[2,3],"canonical_candidate_id":3}]}`}
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry == nil || len(result.Value.Locations) != 3 || !strings.Contains(result.Retry.Message, "overlapping_member") {
t.Fatalf("Normalize() = %#v, %v; want safe retry fallback", result, err)
@@ -117,6 +134,54 @@ func TestNormalizeRejectsUnsafeAndOverlappingGroupsWithoutLosingCandidates(t *te
}
}
func TestReconciliationCandidatesKeepSameNameEvidenceDistinct(t *testing.T) {
doc := semanticDocument()
records := []normalizedRecord{
{location: dnd.Location{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, inputIndexes: []int{0}, earliest: 0},
{location: dnd.Location{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, inputIndexes: []int{1}, earliest: 1},
}
candidates, _, err := reconciliationInputs(records)
if err != nil {
t.Fatalf("reconciliationInputs() error = %v", err)
}
preparation, err := semanticreconcile.Prepare(doc, candidates, semanticreconcile.DefaultLimits())
if err != nil || preparation.Disposition() != semanticreconcile.Ready {
t.Fatalf("Prepare() = %#v, %v; want ready candidates", preparation, err)
}
var candidateInput struct {
Candidates []struct {
CandidateID int `json:"candidate_id"`
Label string `json:"label"`
} `json:"candidates"`
}
if err := json.Unmarshal(preparation.Materials()["candidates"].Content, &candidateInput); err != nil {
t.Fatal(err)
}
if len(candidateInput.Candidates) != 2 || candidateInput.Candidates[0].CandidateID != 1 || candidateInput.Candidates[1].CandidateID != 2 || candidateInput.Candidates[0].Label != "The Tavern" || candidateInput.Candidates[1].Label != "The Tavern" {
t.Fatalf("candidate input = %#v, want distinct integer handles for equal names", candidateInput)
}
}
func TestNormalizeLimitSkipDoesNotCallLLMAndAddsBoundedFallbackWarning(t *testing.T) {
client := &recordingLocationNormalizerClient{}
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Kind: "narration", Text: "A sprawling city"}}}
limit := semanticreconcile.DefaultLimits().MaximumCandidates
input := dnd.LocationRegistry{Locations: make([]dnd.Location, limit+1)}
for index := range input.Locations {
input.Locations[index] = dnd.Location{Name: fmt.Sprintf("Place %d", index), SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}
}
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry != nil {
t.Fatalf("Normalize() = %#v, %v; want deterministic limit fallback", result, err)
}
if len(client.requests) != 0 || len(result.Value.Locations) != limit+1 {
t.Fatalf("completion calls = %d, locations = %d; want no call and all records", len(client.requests), len(result.Value.Locations))
}
if !hasWarning(result.Warnings, ReasonCodeLocationSemanticReconciliationExhausted) || len(result.Warnings) > diagnostics.MaxWarnings {
t.Fatalf("warnings = %#v, want bounded reconciliation fallback", result.Warnings)
}
}
func TestNormalizeHandlesRetryFallbackAndErrors(t *testing.T) {
doc := semanticDocument()
input := dnd.LocationRegistry{Locations: []dnd.Location{{Name: "Old Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {Name: "Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}}}
@@ -152,3 +217,12 @@ func TestNormalizeOrdersEvidenceAndIsIdempotent(t *testing.T) {
t.Fatalf("second Normalize() = %#v, %v; want idempotent value %#v", second, err, first.Value)
}
}
func hasFingerprint(fingerprints []pipeline.CheckpointFingerprint, name string) bool {
for _, fingerprint := range fingerprints {
if fingerprint.Name == name && fingerprint.Value != "" {
return true
}
}
return false
}

View File

@@ -8,19 +8,26 @@ import (
rootassets "gitea.maximumdirect.net/eric/notarius/assets"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const promptAssetRoot = "assets/prompts"
var promptAssetManifest = shared.PromptAssetManifest{
func promptAssetManifest() (shared.PromptAssetManifest, error) {
sharedFiles, err := semanticreconcile.SharedPromptFiles()
if err != nil {
return shared.PromptAssetManifest{}, fmt.Errorf("load shared semantic reconciliation prompt assets: %w", err)
}
return shared.PromptAssetManifest{
ModuleDir: PromptID,
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
{Name: "instructions.md", Path: "prompts/instructions.md"},
{Name: "candidates.md", Path: "prompts/candidates.md"},
},
SharedFiles: []string{"common-dnd-system.md", "common-dnd-entity-reconciliation.md", "common-dnd-transcript-windows.md"},
SharedFiles: []string{"common-dnd-system.md"},
ExternalSharedFiles: sharedFiles,
}, nil
}
func moduleAssetFS() (fs.FS, error) {
@@ -36,7 +43,11 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
if err != nil {
return err
}
promptFS, err := promptAssetManifest.PromptFS(assets)
manifest, err := promptAssetManifest()
if err != nil {
return err
}
promptFS, err := manifest.PromptFS(assets)
if err != nil {
return fmt.Errorf("prepare location normalization prompt assets: %w", err)
}
@@ -50,7 +61,12 @@ func promptAssetMetadata() (string, error) {
promptAssetHashErr = err
return
}
promptAssetHash, promptAssetHashErr = promptAssetManifest.Hash(assets)
manifest, err := promptAssetManifest()
if err != nil {
promptAssetHashErr = err
return
}
promptAssetHash, promptAssetHashErr = manifest.Hash(assets)
})
return promptAssetHash, promptAssetHashErr
}

View File

@@ -7,13 +7,13 @@ import (
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/promptkit"
)
func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) {
registry := llm.NewAssetRegistry()
if err := entityreconcile.RegisterSchemaAssets(registry); err != nil {
if err := semanticreconcile.RegisterAssets(registry); err != nil {
t.Fatal(err)
}
if err := RegisterPromptAssets(registry); err != nil {
@@ -28,11 +28,11 @@ func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) {
if err != nil {
t.Fatal(err)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "location-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"name":"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(`{"candidates":[{"candidate_id":1,"label":"The Tavern","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
if err != nil {
t.Fatal(err)
}
if prepared.OutputContract.SchemaPath != "dnd_entity_reconcile_llm.v1.json" {
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") {
t.Fatalf("prepared prompt = %#v", prepared)
}
for _, index := range []int{2, 4} {

View File

@@ -4,109 +4,77 @@ import (
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
)
type safeReconciliationGroup struct {
members []int
canonical int
}
func reconciliationCandidates(records []normalizedRecord) []entityreconcile.Candidate {
candidates := make([]entityreconcile.Candidate, len(records))
func reconciliationInputs(records []normalizedRecord) ([]semanticreconcile.Candidate, []semanticreconcile.Record[dnd.Location], error) {
candidates := make([]semanticreconcile.Candidate, len(records))
envelopes := make([]semanticreconcile.Record[dnd.Location], len(records))
for index, record := range records {
candidates[index] = entityreconcile.Candidate{Name: record.location.Name, SourceRefs: cloneSourceRefs(record.location.SourceRefs)}
candidates[index] = semanticreconcile.Candidate{
Label: record.location.Name,
SourceRefs: cloneSourceRefs(record.location.SourceRefs),
}
return candidates
envelope, err := semanticreconcile.NewRecord(record.location, record.inputIndexes, record.earliest, cloneLocation)
if err != nil {
return nil, nil, fmt.Errorf("record %d: %w", index, err)
}
envelopes[index] = envelope
}
return candidates, envelopes, nil
}
func reconciliationGroups(assessment entityreconcile.Assessment, candidateKeys []string) []safeReconciliationGroup {
positions := make(map[string]int, len(candidateKeys))
for index, key := range candidateKeys {
positions[key] = index
func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRecord, envelopes []semanticreconcile.Record[dnd.Location], order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning, error) {
application, err := semanticreconcile.ApplyPlan(plan, envelopes, semanticreconcile.ApplicationPolicy[dnd.Location]{
CloneValue: cloneLocation,
ConsolidateGroup: func(members []dnd.Location, canonical dnd.Location) (dnd.Location, error) {
output := cloneLocation(canonical)
output.SourceRefs = nil
for _, member := range members {
output.SourceRefs = append(output.SourceRefs, member.SourceRefs...)
}
safeGroups := assessment.SafeGroups()
groups := make([]safeReconciliationGroup, 0, len(safeGroups))
for _, group := range safeGroups {
members := group.Members()
memberPositions := make([]int, len(members))
valid := true
for index, key := range members {
position, ok := positions[key]
if !ok {
valid = false
break
}
memberPositions[index] = position
}
canonical, ok := positions[group.Canonical()]
if valid && ok {
groups = append(groups, safeReconciliationGroup{members: memberPositions, canonical: canonical})
}
}
return groups
output.SourceRefs = order.Canonicalize(output.SourceRefs)
output.ID = identity.DeriveID(output.Name, output.SourceRefs)
return output, nil
},
})
if err != nil {
return nil, nil, err
}
func reconciliationIssues(assessment entityreconcile.Assessment) []string {
issues := assessment.Issues()
details := make([]string, len(issues))
for index, issue := range issues {
details[index] = fmt.Sprintf("group %d: %s", issue.GroupIndex, issue.Category)
applied := application.Records()
output := make([]normalizedRecord, len(applied))
for index, record := range applied {
output[index] = normalizedRecord{
location: record.Value(),
inputIndexes: record.OriginalInputIndexes(),
earliest: record.EarliestInputPosition(),
}
return details
}
warnings := make([]contracts.Warning, 0, len(application.AppliedGroups()))
for _, event := range application.AppliedGroups() {
provenance := event.Provenance()
warnings = append(warnings, semanticDuplicateWarning(provenance, records[provenance.CanonicalPosition()]))
}
return output, warnings, nil
}
func applySafeGroups(records []normalizedRecord, groups []safeReconciliationGroup, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning) {
byMember := make(map[int]safeReconciliationGroup, len(groups)*2)
for _, group := range groups {
for _, member := range group.members {
byMember[member] = group
}
}
output := make([]normalizedRecord, 0, len(records)-len(groups))
warnings := make([]contracts.Warning, 0, len(groups))
for index, record := range records {
group, grouped := byMember[index]
if !grouped {
output = append(output, cloneRecord(record))
continue
}
if group.members[0] != index {
continue
}
consolidated := consolidateSemanticGroup(records, group, order)
output = append(output, consolidated)
warnings = append(warnings, semanticDuplicateWarning(consolidated, records[group.canonical]))
}
return output, warnings
}
func consolidateSemanticGroup(records []normalizedRecord, group safeReconciliationGroup, order shared.SourceRefOrder) normalizedRecord {
output := cloneRecord(records[group.members[0]])
output.location.Name = records[group.canonical].location.Name
for _, member := range group.members[1:] {
output.location.SourceRefs = append(output.location.SourceRefs, records[member].location.SourceRefs...)
output.inputIndexes = append(output.inputIndexes, records[member].inputIndexes...)
if records[member].earliest < output.earliest {
output.earliest = records[member].earliest
}
}
output.inputIndexes = sortedUniqueIndexes(output.inputIndexes)
output.location.SourceRefs = order.Canonicalize(output.location.SourceRefs)
output.location.ID = identity.DeriveID(output.location.Name, output.location.SourceRefs)
return output
}
func semanticDuplicateWarning(record normalizedRecord, canonical normalizedRecord) contracts.Warning {
details := make([]string, 0, len(record.inputIndexes)+1)
for _, inputIndex := range record.inputIndexes {
func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) contracts.Warning {
inputIndexes := provenance.OriginalInputIndexes()
details := make([]string, 0, len(inputIndexes)+1)
for _, inputIndex := range inputIndexes {
details = append(details, fmt.Sprintf("input index %d", inputIndex))
}
if canonical.earliest != record.earliest {
if canonical.earliest != provenance.EarliestInputPosition() {
details = append(details, fmt.Sprintf("canonical display name from input index %d", canonical.earliest))
}
return contracts.Warning{Scope: locationScope(record.earliest), ReasonCode: ReasonCodeDuplicateLocationCollapsed, Message: diagnostics.Aggregate("semantic duplicate consolidation", details)}
return contracts.Warning{
Scope: locationScope(provenance.EarliestInputPosition()),
ReasonCode: ReasonCodeDuplicateLocationCollapsed,
Message: diagnostics.Aggregate("semantic duplicate consolidation", details),
}
}

View File

@@ -3,14 +3,11 @@ package locationregistry
import (
"context"
"encoding/json"
"strconv"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
)
type recordingLocationNormalizerClient struct {
@@ -28,53 +25,13 @@ func (c *recordingLocationNormalizerClient) CompleteStructured(_ context.Context
if response == "" {
response = `{"duplicate_groups":[]}`
}
content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
content := []byte(response)
if err := json.Unmarshal(content, output); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) {
if !strings.Contains(response, "candidate-") {
return []byte(response), nil
}
var selection struct {
DuplicateGroups []struct {
Members []string `json:"members"`
Canonical string `json:"canonical"`
} `json:"duplicate_groups"`
}
if err := json.Unmarshal([]byte(response), &selection); err != nil {
return nil, err
}
var candidates struct {
Candidates []entityreconcile.Selector `json:"candidates"`
}
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
return nil, err
}
selector := func(key string) entityreconcile.Selector {
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
if err != nil || index < 1 || index > len(candidates.Candidates) {
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
}
return candidates.Candidates[index-1].Clone()
}
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
for index, group := range selection.DuplicateGroups {
members := make([]entityreconcile.Selector, len(group.Members))
for memberIndex, key := range group.Members {
members[memberIndex] = selector(key)
}
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
}
return json.Marshal(proposal)
}
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {
t.Helper()
normalizer, err := New(client, Options{})
@@ -84,7 +41,10 @@ func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normaliz
return normalizer
}
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] {
request := normalizeRequest(value)

View File

@@ -3,7 +3,6 @@ package npcregistry
import (
"context"
"errors"
"fmt"
"reflect"
"sort"
@@ -13,18 +12,18 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
)
const (
Key = "dnd/npc-registry"
PromptID = "dnd.npc_registry.normalize"
normalizationPolicy = "dnd.npc_registry.normalize.v4"
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
PromptVersion = "v1"
normalizationPolicy = "dnd.npc_registry.normalize.v5"
NormalizationPolicy = normalizationPolicy
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
@@ -46,9 +45,7 @@ var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
type Options struct{}
type Normalizer struct {
llm contracts.StructuredLLMClient
promptSHA string
responseSchemaSHA string
engine *semanticreconcile.Engine
}
func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error) {
@@ -59,55 +56,45 @@ func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error
if err != nil {
return nil, normalizerErrorf("load prompt metadata: %w", err)
}
responseSchema, err := entityreconcile.LoadResponseSchema()
engine, err := semanticreconcile.NewEngine(llmClient, semanticreconcile.PromptSpec{
ID: PromptID, Version: PromptVersion, SHA256: promptSHA,
}, semanticreconcile.DefaultLimits())
if err != nil {
return nil, normalizerErrorf("load response schema: %w", err)
return nil, normalizerErrorf("construct semantic reconciliation engine: %w", err)
}
return &Normalizer{llm: llmClient, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256}, nil
return &Normalizer{engine: engine}, nil
}
func (n *Normalizer) Key() string { return Key }
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (n *Normalizer) ManifestMetadata() map[string]any {
if n == nil {
if n == nil || n.engine == nil {
return nil
}
return map[string]any{
"prompt_id": PromptID,
"prompt_version": entityreconcile.SchemaVersion,
"prompt_sha256": n.promptSHA,
"response_schema_key": string(entityreconcile.ResponseSchemaKey),
"response_schema_id": entityreconcile.ResponseSchemaID,
"response_schema_name": entityreconcile.ResponseSchemaName,
"response_schema_version": entityreconcile.SchemaVersion,
"response_schema_sha256": n.responseSchemaSHA,
"identity_policy": identity.Policy,
"normalization_policy": normalizationPolicy,
"semantic_context_policy": semanticContextPolicy,
"semantic_context_radius": semanticContextRadius,
}
metadata := n.engine.ManifestMetadata()
metadata["identity_policy"] = identity.Policy
metadata["normalization_policy"] = normalizationPolicy
return metadata
}
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if n == nil {
if n == nil || n.engine == nil {
return nil
}
return []pipeline.CheckpointFingerprint{
{Name: "prompt", Value: n.promptSHA},
{Name: "response_schema", Value: n.responseSchemaSHA},
{Name: "identity_policy", Value: identity.Policy},
{Name: "normalization_policy", Value: normalizationPolicy},
{Name: "semantic_context_policy", Value: fmt.Sprintf("%s:%d", semanticContextPolicy, semanticContextRadius)},
}
fingerprints := n.engine.CheckpointFingerprints()
return append(fingerprints,
pipeline.CheckpointFingerprint{Name: "identity_policy", Value: identity.Policy},
pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy},
)
}
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCRegistry]) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) {
if n == nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("normalizer must not be nil")
}
if n.llm == nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("LLM client must not be nil")
if n.engine == nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("semantic reconciliation engine must not be nil")
}
if ctx == nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("context must not be nil")
@@ -115,37 +102,50 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
if err := ctx.Err(); err != nil {
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)
records, warnings := preprocessRecords(req.MergeOutput.Value, order)
deterministic := recordList(records)
materials, ready, err := entityreconcile.BuildContext(req.Source, reconciliationCandidates(records), semanticContextRadius)
if err != nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("build semantic context: %w", err)
}
if !ready {
if len(records) < 2 {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
}
var response entityreconcile.ProposalResponse
if _, err := n.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key, PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion,
ProfileID: req.LLMProfile, SessionID: req.SessionID,
Inputs: contracts.LLMInputSet{"candidates": materials.Candidates, "transcript": materials.Transcript},
}, &response); err != nil {
if errors.Is(err, contracts.ErrInvalidStructuredOutput) {
return n.invalidStructuredResult(deterministic, warnings), nil
candidates, envelopes, err := reconciliationInputs(records)
if err != nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("prepare semantic reconciliation inputs: %w", err)
}
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("complete structured output: %w", err)
reconciliation, err := n.engine.Reconcile(ctx, semanticreconcile.Request{
StageName: Key, Source: req.Source, Candidates: candidates,
ProfileID: req.LLMProfile, SessionID: req.SessionID,
})
if err != nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("reconcile semantic duplicates: %w", err)
}
assessment := materials.Assess(response)
applied, semanticWarnings := applySafeGroups(records, reconciliationGroups(assessment, materials.CandidateKeys()), order)
switch reconciliation.Disposition() {
case semanticreconcile.SkippedInsufficientCandidates:
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
case semanticreconcile.SkippedLimitExceeded:
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: deterministic, Warnings: limitWarningsWithSemanticFallback(warnings)}, nil
case semanticreconcile.RetryableInvalidStructuredOutput:
return n.invalidStructuredResult(deterministic, warnings), nil
case semanticreconcile.Complete, semanticreconcile.RetryableDiscardedProposalGroups:
default:
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("unknown semantic reconciliation disposition %d", reconciliation.Disposition())
}
applied, semanticWarnings, err := applyReconciliationPlan(reconciliation.Plan(), records, envelopes, order)
if err != nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("apply semantic reconciliation plan: %w", err)
}
warnings = append(warnings, semanticWarnings...)
if assessment.DiscardedGroups() == 0 {
if reconciliation.Disposition() == semanticreconcile.Complete {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: recordList(applied), Warnings: limitWarnings(warnings)}, nil
}
return retryResult(recordList(applied), warnings, assessment), nil
return retryResult(recordList(applied), warnings, reconciliation), nil
}
func (n *Normalizer) invalidStructuredResult(value dnd.NPCRegistry, warnings []contracts.Warning) contracts.TypedNormalizeResult[dnd.NPCRegistry] {
@@ -160,14 +160,14 @@ func (n *Normalizer) invalidStructuredResult(value dnd.NPCRegistry, warnings []c
}
}
func retryResult(value dnd.NPCRegistry, warnings []contracts.Warning, assessment entityreconcile.Assessment) contracts.TypedNormalizeResult[dnd.NPCRegistry] {
func retryResult(value dnd.NPCRegistry, warnings []contracts.Warning, reconciliation semanticreconcile.Result) contracts.TypedNormalizeResult[dnd.NPCRegistry] {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{
Value: value,
Warnings: limitWarningsForRetry(warnings),
Retry: &contracts.NormalizeRetry{
ReasonCode: ReasonCodeNPCSemanticProposalInvalid,
Message: diagnostics.Aggregate("semantic proposal requires retry", reconciliationIssues(assessment)),
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(assessment.DiscardedGroups())},
Message: diagnostics.Aggregate("semantic proposal requires retry", semanticreconcile.IssueDetails(reconciliation.Issues())),
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(reconciliation.DiscardedGroupCount())},
},
}
}
@@ -199,6 +199,10 @@ func limitWarningsForRetry(warnings []contracts.Warning) []contracts.Warning {
})
}
func limitWarningsWithSemanticFallback(warnings []contracts.Warning) []contracts.Warning {
return append(limitWarningsForRetry(warnings), semanticFallbackWarning(-1))
}
type normalizedRecord struct {
npc dnd.NPC
inputIndexes []int

View File

@@ -4,16 +4,15 @@ import (
"context"
"encoding/json"
"reflect"
"strconv"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
)
func TestModuleContractAndIdentity(t *testing.T) {
@@ -35,12 +34,16 @@ func TestModuleContractAndIdentity(t *testing.T) {
t.Fatal("New(nil, Options{}) error = nil, want nil client rejection")
}
normalizer := newNormalizer(t, &recordingNPCNormalizerClient{})
if metadata := normalizer.ManifestMetadata(); metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy || metadata["prompt_id"] != PromptID || metadata["response_schema_key"] != string(entityreconcile.ResponseSchemaKey) || metadata["response_schema_id"] != entityreconcile.ResponseSchemaID || metadata["response_schema_name"] != entityreconcile.ResponseSchemaName || metadata["semantic_context_policy"] != semanticContextPolicy || metadata["semantic_context_radius"] != semanticContextRadius {
metadata := normalizer.ManifestMetadata()
limits, ok := metadata["semantic_reconciliation_limits"].(map[string]any)
if !ok || metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy || metadata["prompt_id"] != PromptID || metadata["prompt_version"] != PromptVersion || metadata["response_schema_key"] != string(semanticreconcile.ResponseSchemaKey) || metadata["response_schema_id"] != semanticreconcile.ResponseSchemaID || metadata["response_schema_name"] != semanticreconcile.ResponseSchemaName || metadata["semantic_reconciliation_policy"] != semanticreconcile.Policy || len(limits) != 3 {
t.Fatalf("metadata = %#v", metadata)
}
wantFingerprints := []pipeline.CheckpointFingerprint{{Name: "prompt", Value: normalizer.promptSHA}, {Name: "response_schema", Value: normalizer.responseSchemaSHA}, {Name: "identity_policy", Value: identity.Policy}, {Name: "normalization_policy", Value: normalizationPolicy}, {Name: "semantic_context_policy", Value: semanticContextPolicy + ":2"}}
if got := normalizer.CheckpointFingerprints(); !reflect.DeepEqual(got, wantFingerprints) {
t.Fatalf("fingerprints = %#v, want %#v", got, wantFingerprints)
fingerprints := normalizer.CheckpointFingerprints()
for _, name := range []string{"prompt", "response_schema", "semantic_reconciliation_policy", "semantic_reconciliation_limits", "identity_policy", "normalization_policy"} {
if !hasFingerprint(fingerprints, name) {
t.Fatalf("fingerprints = %#v, want %q", fingerprints, name)
}
}
}
@@ -123,6 +126,9 @@ func TestNormalizePreservesInvalidCandidatesAndDoesNotAliasInput(t *testing.T) {
func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) {
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}))
if err != nil || result.Value.NPCs != nil {
t.Fatalf("nil list result = %#v, error = %v", result.Value, err)
@@ -157,53 +163,13 @@ func (c *recordingNPCNormalizerClient) CompleteStructured(_ context.Context, req
if response == "" {
response = `{"duplicate_groups":[]}`
}
content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
content := []byte(response)
if err := json.Unmarshal(content, output); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) {
if !strings.Contains(response, "candidate-") {
return []byte(response), nil
}
var selection struct {
DuplicateGroups []struct {
Members []string `json:"members"`
Canonical string `json:"canonical"`
} `json:"duplicate_groups"`
}
if err := json.Unmarshal([]byte(response), &selection); err != nil {
return nil, err
}
var candidates struct {
Candidates []entityreconcile.Selector `json:"candidates"`
}
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
return nil, err
}
selector := func(key string) entityreconcile.Selector {
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
if err != nil || index < 1 || index > len(candidates.Candidates) {
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
}
return candidates.Candidates[index-1].Clone()
}
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
for index, group := range selection.DuplicateGroups {
members := make([]entityreconcile.Selector, len(group.Members))
for memberIndex, key := range group.Members {
members[memberIndex] = selector(key)
}
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
}
return json.Marshal(proposal)
}
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {
t.Helper()
normalizer, err := New(client, Options{})
@@ -214,7 +180,10 @@ func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normaliz
}
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] {
@@ -231,3 +200,12 @@ func hasWarning(warnings []contracts.Warning, reason, scope string) bool {
}
return false
}
func hasFingerprint(fingerprints []pipeline.CheckpointFingerprint, name string) bool {
for _, fingerprint := range fingerprints {
if fingerprint.Name == name && fingerprint.Value != "" {
return true
}
}
return false
}

View File

@@ -8,23 +8,26 @@ import (
rootassets "gitea.maximumdirect.net/eric/notarius/assets"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const promptAssetRoot = "assets/prompts"
var promptAssetManifest = shared.PromptAssetManifest{
func promptAssetManifest() (shared.PromptAssetManifest, error) {
sharedFiles, err := semanticreconcile.SharedPromptFiles()
if err != nil {
return shared.PromptAssetManifest{}, fmt.Errorf("load shared semantic reconciliation prompt assets: %w", err)
}
return shared.PromptAssetManifest{
ModuleDir: PromptID,
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
{Name: "instructions.md", Path: "prompts/instructions.md"},
{Name: "candidates.md", Path: "prompts/candidates.md"},
},
SharedFiles: []string{
"common-dnd-system.md",
"common-dnd-entity-reconciliation.md",
"common-dnd-transcript-windows.md",
},
SharedFiles: []string{"common-dnd-system.md"},
ExternalSharedFiles: sharedFiles,
}, nil
}
func moduleAssetFS() (fs.FS, error) {
@@ -40,7 +43,11 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
if err != nil {
return err
}
promptFS, err := promptAssetManifest.PromptFS(assets)
manifest, err := promptAssetManifest()
if err != nil {
return err
}
promptFS, err := manifest.PromptFS(assets)
if err != nil {
return fmt.Errorf("prepare NPC normalization prompt assets: %w", err)
}
@@ -54,7 +61,12 @@ func promptAssetMetadata() (string, error) {
promptAssetHashErr = err
return
}
promptAssetHash, promptAssetHashErr = promptAssetManifest.Hash(assets)
manifest, err := promptAssetManifest()
if err != nil {
promptAssetHashErr = err
return
}
promptAssetHash, promptAssetHashErr = manifest.Hash(assets)
})
return promptAssetHash, promptAssetHashErr
}

View File

@@ -7,7 +7,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/promptkit"
)
@@ -16,8 +16,8 @@ func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) {
t.Fatalf("promptAssetMetadata() = %q, %v; want prompt fingerprint", promptHash, err)
}
registry := llm.NewAssetRegistry()
if err := entityreconcile.RegisterSchemaAssets(registry); err != nil {
t.Fatalf("RegisterSchemaAssets() error = %v", err)
if err := semanticreconcile.RegisterAssets(registry); err != nil {
t.Fatalf("RegisterAssets() error = %v", err)
}
if err := RegisterPromptAssets(registry); err != nil {
t.Fatalf("RegisterPromptAssets() error = %v", err)
@@ -34,21 +34,27 @@ func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) {
t.Fatalf("NewEngine() error = %v", err)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "normalize-test-profile",
PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "normalize-test-profile",
Inputs: map[string]promptkit.ArtifactRef{
"candidates": promptkit.Inline(`{"candidates":[{"name":"Mira","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
"candidates": promptkit.Inline(`{"candidates":[{"candidate_id":1,"label":"Mira","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
"transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`),
},
})
if err != nil {
t.Fatalf("Prepare() error = %v", err)
}
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_entity_reconcile_llm.v1.json" {
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "semantic_reconciliation_llm.v1.json" {
t.Fatalf("prepared prompt = %#v, want normalization prompt identity and schema", prepared)
}
if prepared.Messages[0].Role != "system" {
t.Fatalf("initial message role = %q, want system", prepared.Messages[0].Role)
}
if !strings.Contains(prepared.Messages[1].Content, "candidate_id") || !strings.Contains(prepared.Messages[1].Content, "integer") {
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") {
t.Fatalf("NPC policy message = %q, want domain distinctions without copied ranges", prepared.Messages[2].Content)
}
for _, index := range []int{2, 4} {
if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral {
t.Errorf("message %d cache control = %#v, want ephemeral", index, cache)

View File

@@ -4,118 +4,76 @@ import (
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
)
const semanticContextRadius = 2
type safeReconciliationGroup struct {
members []int
canonical int
}
func reconciliationCandidates(records []normalizedRecord) []entityreconcile.Candidate {
candidates := make([]entityreconcile.Candidate, len(records))
func reconciliationInputs(records []normalizedRecord) ([]semanticreconcile.Candidate, []semanticreconcile.Record[dnd.NPC], error) {
candidates := make([]semanticreconcile.Candidate, len(records))
envelopes := make([]semanticreconcile.Record[dnd.NPC], len(records))
for index, record := range records {
candidates[index] = entityreconcile.Candidate{
Name: record.npc.Name,
candidates[index] = semanticreconcile.Candidate{
Label: record.npc.Name,
SourceRefs: cloneSourceRefs(record.npc.SourceRefs),
}
envelope, err := semanticreconcile.NewRecord(record.npc, record.inputIndexes, record.earliest, cloneNPC)
if err != nil {
return nil, nil, fmt.Errorf("record %d: %w", index, err)
}
return candidates
envelopes[index] = envelope
}
return candidates, envelopes, nil
}
func reconciliationGroups(assessment entityreconcile.Assessment, candidateKeys []string) []safeReconciliationGroup {
positions := make(map[string]int, len(candidateKeys))
for index, key := range candidateKeys {
positions[key] = index
func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRecord, envelopes []semanticreconcile.Record[dnd.NPC], order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning, error) {
application, err := semanticreconcile.ApplyPlan(plan, envelopes, semanticreconcile.ApplicationPolicy[dnd.NPC]{
CloneValue: cloneNPC,
ConsolidateGroup: func(members []dnd.NPC, canonical dnd.NPC) (dnd.NPC, error) {
output := cloneNPC(canonical)
output.SourceRefs = nil
for _, member := range members {
output.SourceRefs = append(output.SourceRefs, member.SourceRefs...)
}
safeGroups := assessment.SafeGroups()
groups := make([]safeReconciliationGroup, 0, len(safeGroups))
for _, group := range safeGroups {
members := group.Members()
memberPositions := make([]int, len(members))
valid := true
for index, key := range members {
position, ok := positions[key]
if !ok {
valid = false
break
}
memberPositions[index] = position
}
canonical, ok := positions[group.Canonical()]
if !valid || !ok {
continue
}
groups = append(groups, safeReconciliationGroup{members: memberPositions, canonical: canonical})
}
return groups
output.SourceRefs = order.Canonicalize(output.SourceRefs)
output.ID = identity.DeriveID(output.Name)
return output, nil
},
})
if err != nil {
return nil, nil, err
}
func reconciliationIssues(assessment entityreconcile.Assessment) []string {
issues := assessment.Issues()
details := make([]string, len(issues))
for index, issue := range issues {
details[index] = fmt.Sprintf("group %d: %s", issue.GroupIndex, issue.Category)
applied := application.Records()
output := make([]normalizedRecord, len(applied))
for index, record := range applied {
output[index] = normalizedRecord{
npc: record.Value(),
inputIndexes: record.OriginalInputIndexes(),
earliest: record.EarliestInputPosition(),
}
return details
}
warnings := make([]contracts.Warning, 0, len(application.AppliedGroups()))
for _, event := range application.AppliedGroups() {
provenance := event.Provenance()
warnings = append(warnings, semanticDuplicateWarning(provenance, records[provenance.CanonicalPosition()]))
}
return output, warnings, nil
}
func applySafeGroups(records []normalizedRecord, groups []safeReconciliationGroup, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning) {
byMember := make(map[int]safeReconciliationGroup, len(groups)*2)
for _, group := range groups {
for _, member := range group.members {
byMember[member] = group
}
}
output := make([]normalizedRecord, 0, len(records)-len(groups))
warnings := make([]contracts.Warning, 0, len(groups))
for index, record := range records {
group, grouped := byMember[index]
if !grouped {
output = append(output, cloneRecord(record))
continue
}
if group.members[0] != index {
continue
}
consolidated := consolidateSemanticGroup(records, group, order)
output = append(output, consolidated)
warnings = append(warnings, semanticDuplicateWarning(consolidated, records[group.canonical]))
}
return output, warnings
}
func consolidateSemanticGroup(records []normalizedRecord, group safeReconciliationGroup, order shared.SourceRefOrder) normalizedRecord {
output := cloneRecord(records[group.members[0]])
output.npc.Name = records[group.canonical].npc.Name
for _, member := range group.members[1:] {
output.npc.SourceRefs = append(output.npc.SourceRefs, records[member].npc.SourceRefs...)
output.inputIndexes = append(output.inputIndexes, records[member].inputIndexes...)
if records[member].earliest < output.earliest {
output.earliest = records[member].earliest
}
}
output.inputIndexes = sortedUniqueIndexes(output.inputIndexes)
output.npc.SourceRefs = order.Canonicalize(output.npc.SourceRefs)
output.npc.ID = identity.DeriveID(output.npc.Name)
return output
}
func semanticDuplicateWarning(record normalizedRecord, canonical normalizedRecord) contracts.Warning {
details := make([]string, 0, len(record.inputIndexes)+1)
for _, inputIndex := range record.inputIndexes {
func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) contracts.Warning {
inputIndexes := provenance.OriginalInputIndexes()
details := make([]string, 0, len(inputIndexes)+1)
for _, inputIndex := range inputIndexes {
details = append(details, fmt.Sprintf("input index %d", inputIndex))
}
if canonical.earliest != record.earliest {
if canonical.earliest != provenance.EarliestInputPosition() {
details = append(details, fmt.Sprintf("canonical display name from input index %d", canonical.earliest))
}
return contracts.Warning{
Scope: npcScope(record.earliest),
Scope: npcScope(provenance.EarliestInputPosition()),
ReasonCode: ReasonCodeDuplicateNPCCollapsed,
Message: diagnostics.Aggregate("semantic duplicate consolidation", details),
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"reflect"
"strings"
@@ -11,9 +12,10 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
)
func TestNormalizeSkipsSemanticCompletionWithoutTwoEligibleCandidates(t *testing.T) {
@@ -31,7 +33,7 @@ func TestNormalizeSkipsSemanticCompletionWithoutTwoEligibleCandidates(t *testing
}
func TestNormalizeAppliesSafeProposalAndUsesPrivateInputs(t *testing.T) {
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`}
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`}
normalizer := newNormalizer(t, client)
doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
@@ -64,17 +66,28 @@ func TestNormalizeAppliesSafeProposalAndUsesPrivateInputs(t *testing.T) {
t.Fatalf("completion calls = %d, want one", len(client.requests))
}
completion := client.requests[0]
if completion.StageName != Key || completion.PromptID != PromptID || completion.PromptVersion != entityreconcile.SchemaVersion || completion.ProfileID != request.LLMProfile || completion.SessionID != request.SessionID || len(completion.Inputs) != 2 {
if completion.StageName != Key || completion.PromptID != PromptID || completion.PromptVersion != PromptVersion || completion.ProfileID != request.LLMProfile || completion.SessionID != request.SessionID || len(completion.Inputs) != 2 {
t.Fatalf("completion request = %#v, want normalize request identity and exactly two inputs", completion)
}
encoded := string(completion.Inputs["candidates"].Content) + string(completion.Inputs["transcript"].Content)
if strings.Contains(encoded, "npc:sha256:") || strings.Contains(encoded, doc.ID) {
t.Fatalf("completion inputs leaked private identifiers: %s", encoded)
}
var visible struct {
Candidates []struct {
CandidateID int `json:"candidate_id"`
} `json:"candidates"`
}
if err := json.Unmarshal(completion.Inputs["candidates"].Content, &visible); err != nil {
t.Fatalf("decode candidates: %v", err)
}
if got := []int{visible.Candidates[0].CandidateID, visible.Candidates[1].CandidateID, visible.Candidates[2].CandidateID}; !reflect.DeepEqual(got, []int{1, 2, 3}) {
t.Fatalf("candidate IDs = %v, want contiguous request-local handles", got)
}
}
func TestNormalizeUnsafeProposalReturnsSafeRetryFallback(t *testing.T) {
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"},{"members":["candidate-000003","unknown"],"canonical":"candidate-000003"}]}`}
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2},{"candidate_ids":[3,99],"canonical_candidate_id":3}]}`}
normalizer := newNormalizer(t, client)
doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
@@ -137,7 +150,7 @@ func TestNormalizeRedactsContextMaterialFailures(t *testing.T) {
request := normalizeRequestWithSource(input, doc)
request.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(transcript), "sha256:test", originPath)
_, err := normalizer.Normalize(context.Background(), request)
if err == nil || !strings.Contains(err.Error(), "build entity reconciliation context: invalid source metadata") {
if err == nil || !strings.Contains(err.Error(), "build transcript material: invalid source metadata") {
t.Fatalf("Normalize() error = %v; want content-safe context-material failure", err)
}
for _, forbidden := range []string{metadataKey, metadataValue, transcript, firstName, secondName, sourceID, originPath, "float64", "non-finite"} {
@@ -152,8 +165,8 @@ func TestNormalizeRedactsContextMaterialFailures(t *testing.T) {
func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) {
client := &recordingNPCNormalizerClient{responses: []string{
`{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"},{"members":["candidate-000003","unknown"],"canonical":"candidate-000003"}]}`,
`{"duplicate_groups":[{"members":["candidate-000001","candidate-000003"],"canonical":"candidate-000003"}]}`,
`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2},{"candidate_ids":[3,99],"canonical_candidate_id":3}]}`,
`{"duplicate_groups":[{"candidate_ids":[1,3],"canonical_candidate_id":3}]}`,
}}
normalizer := newNormalizer(t, client)
doc := semanticDocument()
@@ -175,8 +188,8 @@ func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) {
}
}
func TestNormalizeRetainsRecordsWhenAProposalUsesAnUnknownDescriptor(t *testing.T) {
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","unknown"],"canonical":"candidate-000001"}]}`}
func TestNormalizeRetainsRecordsWhenAProposalUsesAnUnknownHandle(t *testing.T) {
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,99],"canonical_candidate_id":1}]}`}
normalizer := newNormalizer(t, client)
doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
@@ -193,32 +206,52 @@ func TestNormalizeRetainsRecordsWhenAProposalUsesAnUnknownDescriptor(t *testing.
}
}
func TestReconciliationCandidatesKeepEqualDisplayNamesDistinct(t *testing.T) {
func TestReconciliationCandidatesKeepEqualContextualDescriptorsDistinct(t *testing.T) {
doc := semanticDocument()
records := []normalizedRecord{
{npc: dnd.NPC{Name: "The Guard", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}},
{npc: dnd.NPC{Name: "The Guard", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}},
}
materials, ready, err := entityreconcile.BuildContext(doc, reconciliationCandidates(records), semanticContextRadius)
if err != nil || !ready {
t.Fatalf("BuildContext() = %#v, %t, %v; want ready keyed candidates", materials, ready, err)
candidates, _, err := reconciliationInputs(records)
if err != nil {
t.Fatalf("reconciliationInputs() error = %v", err)
}
keys := materials.CandidateKeys()
if !reflect.DeepEqual(keys, []string{"candidate-000001", "candidate-000002"}) || strings.Count(string(materials.Candidates.Content), `"The Guard"`) != 2 {
t.Fatalf("candidate keys and inputs = %#v, %s; want distinct equal-display candidates", keys, materials.Candidates.Content)
preparation, err := semanticreconcile.Prepare(doc, candidates, semanticreconcile.DefaultLimits())
if err != nil || preparation.Disposition() != semanticreconcile.Ready {
t.Fatalf("Prepare() = %#v, %v; want ready candidates", preparation, err)
}
var candidateInput struct {
Candidates []entityreconcile.Selector `json:"candidates"`
Candidates []struct {
CandidateID int `json:"candidate_id"`
Label string `json:"label"`
} `json:"candidates"`
}
if err := json.Unmarshal(materials.Candidates.Content, &candidateInput); err != nil {
if err := json.Unmarshal(preparation.Materials()["candidates"].Content, &candidateInput); err != nil {
t.Fatal(err)
}
assessment := materials.Assess(entityreconcile.ProposalResponse{DuplicateGroups: []entityreconcile.DuplicateGroup{{
Members: candidateInput.Candidates, Canonical: candidateInput.Candidates[1],
}}})
groups := reconciliationGroups(assessment, keys)
if assessment.DiscardedGroups() != 0 || len(groups) != 1 || groups[0].canonical != 1 {
t.Fatalf("reconciliation = %#v, %#v; want distinct keyed group", assessment, groups)
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" {
t.Fatalf("candidate input = %#v, want distinct integer handles for equal descriptors", candidateInput)
}
}
func TestNormalizeLimitSkipDoesNotCallLLMAndAddsBoundedFallbackWarning(t *testing.T) {
client := &recordingNPCNormalizerClient{}
normalizer := newNormalizer(t, client)
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Kind: "speech", Text: "A crowded hall"}}}
limit := semanticreconcile.DefaultLimits().MaximumCandidates
input := dnd.NPCRegistry{NPCs: make([]dnd.NPC, limit+1)}
for index := range input.NPCs {
input.NPCs[index] = dnd.NPC{Name: fmt.Sprintf("Person %d", index), SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}
}
result, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry != nil {
t.Fatalf("Normalize() = %#v, %v; want deterministic limit fallback", result, err)
}
if len(client.requests) != 0 || len(result.Value.NPCs) != limit+1 {
t.Fatalf("completion calls = %d, NPCs = %d; want no call and all records", len(client.requests), len(result.Value.NPCs))
}
if !hasWarning(result.Warnings, ReasonCodeNPCSemanticReconciliationExhausted, "npcs") || len(result.Warnings) > diagnostics.MaxWarnings {
t.Fatalf("warnings = %#v, want bounded reconciliation fallback", result.Warnings)
}
}

View File

@@ -35,7 +35,6 @@ import (
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcregistry"
scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions"
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
)
@@ -147,7 +146,6 @@ func registerModules(registries pipeline.Registries) error {
func registerPromptAssets(assets *llm.AssetRegistry) error {
return runRegistrations([]registration{
{name: "entity reconciliation schema assets", register: func() error { return entityreconcile.RegisterSchemaAssets(assets) }},
{name: "scenes prompt assets", register: func() error { return scenes.RegisterPromptAssets(assets) }},
{name: "spells prompt assets", register: func() error { return spellextract.RegisterPromptAssets(assets) }},
{name: "npc registry prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }},

View File

@@ -77,13 +77,6 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
if _, err := fs.ReadFile(fallbackFS, "dnd-extraction.yaml"); err != nil {
t.Fatalf("fallback profile asset = %v, want registered D&D profile", err)
}
schemaFS, err := assets.SchemaFS()
if err != nil {
t.Fatalf("SchemaFS() error = %v", err)
}
if _, err := fs.ReadFile(schemaFS, "dnd_entity_reconcile_llm.v1.json"); err != nil {
t.Fatalf("entity reconciliation schema asset = %v, want registered shared schema", err)
}
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"})
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, enemyeventextract.Key, itemoccurrenceextract.Key, itemregistryextract.Key, occurrenceextract.Key, scenedescriptionextract.Key, locationextract.Key, locationoccurrenceextract.Key})
assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, enemyeventnormalize.Key, itemoccurrencenormalize.Key, itemregistrynormalize.Key, occurrencenormalize.Key, scenedescriptionnormalize.Key, locationnormalize.Key, locationoccurrencenormalize.Key, pipeline.DefaultNormalizeModule})

View File

@@ -11,12 +11,14 @@ import (
)
// PromptAssetManifest is the ordered set of assets that make up one prompt.
// Module files are addressed in the owning module filesystem; shared files use
// the names in sharedPromptPaths and are mounted beneath sharedassets.
// Module files are addressed in the owning module filesystem. SharedFiles use
// names in sharedPromptPaths, while ExternalSharedFiles are explicit
// caller-owned descriptors. Both kinds are mounted beneath sharedassets.
type PromptAssetManifest struct {
ModuleDir string
ModuleFiles []promptfs.ModulePromptFile
SharedFiles []string
ExternalSharedFiles []promptfs.SharedPromptFile
}
var sharedPromptPaths = map[string]string{
@@ -25,10 +27,8 @@ var sharedPromptPaths = map[string]string{
"common-dnd-identity.md": "prompts/common-dnd-identity.md",
"common-dnd-transcript-full.md": "prompts/common-dnd-transcript-full.md",
"common-dnd-transcript-chunk.md": "prompts/common-dnd-transcript-chunk.md",
"common-dnd-transcript-windows.md": "prompts/common-dnd-transcript-windows.md",
"common-dnd-references.md": "prompts/common-dnd-references.md",
"common-dnd-npc-registry.md": "prompts/common-dnd-npc-registry.md",
"common-dnd-entity-reconciliation.md": "prompts/common-dnd-entity-reconciliation.md",
}
func sharedAssetFS() (fs.FS, error) {
@@ -40,7 +40,7 @@ func sharedAssetFS() (fs.FS, error) {
}
func (manifest PromptAssetManifest) PromptFS(moduleFS fs.FS) (fs.FS, error) {
sharedFiles, err := resolveSharedPromptFiles(manifest.SharedFiles)
sharedFiles, err := manifest.sharedPromptFiles()
if err != nil {
return nil, err
}
@@ -49,10 +49,14 @@ func (manifest PromptAssetManifest) PromptFS(moduleFS fs.FS) (fs.FS, error) {
}
func (manifest PromptAssetManifest) Hash(moduleFS fs.FS) (string, error) {
sharedFiles, err := resolveSharedPromptFiles(manifest.SharedFiles)
sharedFiles, err := manifest.sharedPromptFiles()
if err != nil {
return "", err
}
moduleFiles := append([]promptfs.ModulePromptFile(nil), manifest.ModuleFiles...)
if _, err := promptfs.ModulePromptFS(manifest.ModuleDir, moduleFS, moduleFiles, sharedFiles...); err != nil {
return "", err
}
parts := make([]llm.AssetHashPart, 0, len(manifest.ModuleFiles)+len(sharedFiles))
for _, file := range manifest.ModuleFiles {
parts = append(parts, llm.AssetHashPart{FS: moduleFS, Path: file.Path})
@@ -63,6 +67,14 @@ func (manifest PromptAssetManifest) Hash(moduleFS fs.FS) (string, error) {
return llm.HashAssets(parts)
}
func (manifest PromptAssetManifest) sharedPromptFiles() ([]promptfs.SharedPromptFile, error) {
files, err := resolveSharedPromptFiles(manifest.SharedFiles)
if err != nil {
return nil, err
}
return append(files, manifest.ExternalSharedFiles...), nil
}
func resolveSharedPromptFiles(names []string) ([]promptfs.SharedPromptFile, error) {
assets, err := sharedAssetFS()
if err != nil {

View File

@@ -22,7 +22,6 @@ func TestPromptAssetManifestPromptFS(t *testing.T) {
"common-dnd-system.md",
"common-dnd-transcript-full.md",
"common-dnd-transcript-chunk.md",
"common-dnd-transcript-windows.md",
},
}
@@ -51,7 +50,6 @@ func TestPromptAssetManifestPromptFS(t *testing.T) {
"assets/prompts/dnd.test/sharedassets/common-dnd-system.md",
"assets/prompts/dnd.test/sharedassets/common-dnd-transcript-full.md",
"assets/prompts/dnd.test/sharedassets/common-dnd-transcript-chunk.md",
"assets/prompts/dnd.test/sharedassets/common-dnd-transcript-windows.md",
} {
content, err := fs.ReadFile(fsys, path)
if err != nil {
@@ -207,3 +205,106 @@ func TestSharedPromptDescriptorsReturnFreshCopies(t *testing.T) {
t.Fatalf("resolveSharedPromptFiles() reused descriptor state: first=%#v second=%#v", first, second)
}
}
func TestPromptAssetManifestMountsExternalSharedFiles(t *testing.T) {
external := fstest.MapFS{"core/protocol.md": {Data: []byte("integer protocol")}}
manifest := PromptAssetManifest{
ModuleDir: "dnd.test",
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
},
ExternalSharedFiles: []promptfs.SharedPromptFile{
{Name: "protocol.md", FS: external, Path: "core/protocol.md"},
},
}
fys, err := manifest.PromptFS(fstest.MapFS{
"prompts/prompt.yaml": {Data: []byte("id: dnd.test")},
})
if err != nil {
t.Fatalf("PromptFS() error = %v, want nil", err)
}
content, err := fs.ReadFile(fys, "assets/prompts/dnd.test/sharedassets/protocol.md")
if err != nil || string(content) != "integer protocol" {
t.Fatalf("mounted external protocol = %q, %v", content, err)
}
}
func TestPromptAssetManifestHashIncludesExternalSharedFiles(t *testing.T) {
moduleFS := fstest.MapFS{"prompts/prompt.yaml": {Data: []byte("id: dnd.test")}}
external := fstest.MapFS{"core/protocol.md": {Data: []byte("integer protocol")}}
manifest := PromptAssetManifest{
ModuleDir: "dnd.test",
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
},
ExternalSharedFiles: []promptfs.SharedPromptFile{
{Name: "protocol.md", FS: external, Path: "core/protocol.md"},
},
}
got, err := manifest.Hash(moduleFS)
if err != nil {
t.Fatalf("Hash() error = %v, want nil", err)
}
want, err := llm.HashAssets([]llm.AssetHashPart{
{FS: moduleFS, Path: "prompts/prompt.yaml"},
{FS: external, Path: "core/protocol.md"},
})
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("Hash() = %q, want external-aware hash %q", got, want)
}
}
func TestPromptAssetManifestRejectsInvalidExternalSharedFiles(t *testing.T) {
moduleFS := fstest.MapFS{"prompts/prompt.yaml": {Data: []byte("id: dnd.test")}}
tests := []struct {
name string
external []promptfs.SharedPromptFile
shared []string
want string
}{
{
name: "missing",
external: []promptfs.SharedPromptFile{
{Name: "protocol.md", FS: fstest.MapFS{}, Path: "core/protocol.md"},
},
want: "read shared prompt asset core/protocol.md",
},
{
name: "duplicate external destinations",
external: []promptfs.SharedPromptFile{
{Name: "protocol.md", FS: fstest.MapFS{"a.md": {Data: []byte("a")}}, Path: "a.md"},
{Name: " protocol.md ", FS: fstest.MapFS{"b.md": {Data: []byte("b")}}, Path: "b.md"},
},
want: `duplicate sharedassets prompt destination "assets/prompts/dnd.test/sharedassets/protocol.md"`,
},
{
name: "duplicate named and external destinations",
shared: []string{"common-dnd-system.md"},
external: []promptfs.SharedPromptFile{
{Name: "common-dnd-system.md", FS: fstest.MapFS{"system.md": {Data: []byte("external")}}, Path: "system.md"},
},
want: `duplicate sharedassets prompt destination "assets/prompts/dnd.test/sharedassets/common-dnd-system.md"`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
manifest := PromptAssetManifest{
ModuleDir: "dnd.test",
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
},
SharedFiles: test.shared,
ExternalSharedFiles: test.external,
}
if _, err := manifest.PromptFS(moduleFS); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("PromptFS() error = %v, want %q", err, test.want)
}
if _, err := manifest.Hash(moduleFS); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Hash() error = %v, want %q", err, test.want)
}
})
}
}

View File

@@ -1,292 +0,0 @@
// Package entityreconcile provides safe, D&D-specific duplicate proposal
// materials shared by entity normalizers.
package entityreconcile
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
const candidateKeyFormat = "candidate-%06d"
// Candidate is one domain-neutral entity candidate supplied by a normalizer.
// BuildContext never retains or mutates its source references.
type Candidate struct {
Name string
SourceRefs []source.SourceRef
}
// Selector identifies one candidate through its canonical name and source-free
// evidence ranges. It is the complete model-facing candidate descriptor.
type Selector struct {
Name string `json:"name"`
SourceRefs []SourceRange `json:"source_refs"`
}
// Clone returns an owned copy of the selector.
func (s Selector) Clone() Selector {
s.SourceRefs = cloneSourceRanges(s.SourceRefs)
return s
}
// SourceRange is a source-free evidence coordinate used in a selector.
type SourceRange struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
}
// Materials contains owned prompt inputs and opaque candidate-key mappings.
type Materials struct {
Candidates contracts.LLMInputMaterial
Transcript contracts.LLMInputMaterial
candidateKeys []string
eligible map[string]struct{}
keyBySelector map[string]string
collidedSelectors map[string]struct{}
}
// CandidateKeys returns all deterministic keys in candidate input order.
func (m Materials) CandidateKeys() []string {
return append([]string(nil), m.candidateKeys...)
}
// EligibleCandidateKeys returns only candidates whose evidence safely produced
// transcript context, preserving candidate input order.
func (m Materials) EligibleCandidateKeys() []string {
keys := make([]string, 0, len(m.eligible))
for _, key := range m.candidateKeys {
if _, ok := m.eligible[key]; ok {
keys = append(keys, key)
}
}
return keys
}
type candidateInput struct {
Candidates []Selector `json:"candidates"`
}
type transcriptInput struct {
Windows []transcriptWindow `json:"windows"`
}
type transcriptWindow struct {
Units []transcriptUnit `json:"units"`
}
type transcriptUnit struct {
ID int `json:"id"`
Kind string `json:"kind"`
Text string `json:"text"`
Metadata map[string]any `json:"metadata,omitempty"`
Cited bool `json:"cited"`
}
type sourceInterval struct {
start int
end int
}
// BuildContext constructs bounded, source-ordered prompt inputs. It returns
// ready=false when fewer than two candidates have safe context.
func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int) (Materials, bool, error) {
if radius < 0 {
return Materials{}, false, fmt.Errorf("build entity reconciliation context: radius must not be negative")
}
materials := Materials{
candidateKeys: make([]string, len(candidates)),
eligible: make(map[string]struct{}),
keyBySelector: make(map[string]string),
collidedSelectors: make(map[string]struct{}),
}
for index := range candidates {
key := fmt.Sprintf(candidateKeyFormat, index+1)
materials.candidateKeys[index] = key
}
if doc == nil {
return materials, false, nil
}
index := source.NewDocumentIndex(doc)
type preparedCandidate struct {
key string
selector Selector
intervals []sourceInterval
lookupKey string
}
prepared := make([]preparedCandidate, 0, len(candidates))
selectorCounts := make(map[string]int, len(candidates))
views := make([]Selector, 0, len(candidates))
intervals := make([]sourceInterval, 0)
cited := make([]bool, len(doc.Units))
for candidateIndex, candidate := range candidates {
references, candidateIntervals, valid := candidateReferences(index, candidate.SourceRefs)
if !valid {
continue
}
key := materials.candidateKeys[candidateIndex]
selector := Selector{Name: candidate.Name, SourceRefs: references}
lookupKey, err := selectorLookupKey(selector)
if err != nil {
return Materials{}, false, fmt.Errorf("build entity reconciliation context: invalid candidate material")
}
prepared = append(prepared, preparedCandidate{key: key, selector: selector, intervals: candidateIntervals, lookupKey: lookupKey})
selectorCounts[lookupKey]++
}
for _, candidate := range prepared {
if selectorCounts[candidate.lookupKey] != 1 {
materials.collidedSelectors[candidate.lookupKey] = struct{}{}
continue
}
materials.eligible[candidate.key] = struct{}{}
materials.keyBySelector[candidate.lookupKey] = candidate.key
views = append(views, candidate.selector.Clone())
for _, interval := range candidate.intervals {
for position := interval.start; position <= interval.end; position++ {
cited[position] = true
}
intervals = append(intervals, sourceInterval{
start: maxInt(0, interval.start-radius),
end: minInt(len(doc.Units)-1, interval.end+radius),
})
}
}
if len(views) < 2 {
return materials, false, nil
}
windows, err := contextWindows(doc.Units, coalesceIntervals(intervals), cited)
if err != nil {
return Materials{}, false, fmt.Errorf("build entity reconciliation context: invalid source metadata")
}
candidateContent, err := json.Marshal(candidateInput{Candidates: views})
if err != nil {
return Materials{}, false, fmt.Errorf("build entity reconciliation context: invalid candidate material")
}
transcriptContent, err := json.Marshal(transcriptInput{Windows: windows})
if err != nil {
return Materials{}, false, fmt.Errorf("build entity reconciliation context: invalid transcript material")
}
materials.Candidates = newInputMaterial("candidates", candidateContent)
materials.Transcript = newInputMaterial("transcript", transcriptContent)
return materials, true, nil
}
func candidateReferences(index source.DocumentIndex, refs []source.SourceRef) ([]SourceRange, []sourceInterval, bool) {
if len(refs) == 0 {
return nil, nil, false
}
type referencedInterval struct {
reference SourceRange
interval sourceInterval
}
prepared := make([]referencedInterval, 0, len(refs))
for _, ref := range refs {
if err := index.ValidateRef(ref); err != nil {
return nil, nil, false
}
start, _ := index.Position(ref.StartUnitID)
end, _ := index.Position(ref.EndUnitID)
prepared = append(prepared, referencedInterval{reference: SourceRange{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}, interval: sourceInterval{start: start, end: end}})
}
sort.Slice(prepared, func(left, right int) bool {
if prepared[left].interval.start != prepared[right].interval.start {
return prepared[left].interval.start < prepared[right].interval.start
}
return prepared[left].interval.end < prepared[right].interval.end
})
references := make([]SourceRange, 0, len(prepared))
intervals := make([]sourceInterval, 0, len(prepared))
for _, item := range prepared {
if len(references) > 0 && references[len(references)-1] == item.reference {
continue
}
references = append(references, item.reference)
intervals = append(intervals, item.interval)
}
return references, intervals, true
}
func selectorLookupKey(selector Selector) (string, error) {
content, err := json.Marshal(selector.Clone())
if err != nil {
return "", err
}
return string(content), nil
}
func cloneSourceRanges(values []SourceRange) []SourceRange {
if len(values) == 0 {
return []SourceRange{}
}
return append([]SourceRange(nil), values...)
}
func coalesceIntervals(intervals []sourceInterval) []sourceInterval {
if len(intervals) == 0 {
return nil
}
ordered := append([]sourceInterval(nil), intervals...)
sort.Slice(ordered, func(left, right int) bool {
if ordered[left].start != ordered[right].start {
return ordered[left].start < ordered[right].start
}
return ordered[left].end < ordered[right].end
})
coalesced := make([]sourceInterval, 0, len(ordered))
for _, interval := range ordered {
if len(coalesced) == 0 || interval.start > coalesced[len(coalesced)-1].end+1 {
coalesced = append(coalesced, interval)
continue
}
if interval.end > coalesced[len(coalesced)-1].end {
coalesced[len(coalesced)-1].end = interval.end
}
}
return coalesced
}
func contextWindows(units []source.SourceUnit, intervals []sourceInterval, cited []bool) ([]transcriptWindow, error) {
windows := make([]transcriptWindow, 0, len(intervals))
for _, interval := range intervals {
window := transcriptWindow{Units: make([]transcriptUnit, 0, interval.end-interval.start+1)}
for position := interval.start; position <= interval.end; position++ {
unit := units[position]
metadata, err := source.CloneMetadata(unit.Metadata)
if err != nil {
return nil, err
}
window.Units = append(window.Units, transcriptUnit{
ID: unit.ID, Kind: unit.Kind, Text: unit.Text, Metadata: metadata, Cited: cited[position],
})
}
windows = append(windows, window)
}
return windows, nil
}
func newInputMaterial(name string, content []byte) contracts.LLMInputMaterial {
digest := sha256.Sum256(content)
return contracts.NewLLMInputMaterial(name, "application/json", content, "sha256:"+hex.EncodeToString(digest[:]), "")
}
func minInt(left, right int) int {
if left < right {
return left
}
return right
}
func maxInt(left, right int) int {
if left > right {
return left
}
return right
}

View File

@@ -1,328 +0,0 @@
package entityreconcile
import (
"bytes"
"encoding/json"
"io/fs"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"github.com/santhosh-tekuri/jsonschema/v6"
)
func TestBuildContextUsesContextualSelectorsSourceOrderAndOwnedData(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 40, Kind: "narration", Text: "zero"},
{ID: 10, Kind: "speech", Text: "one", Metadata: map[string]any{"speaker": map[string]any{"name": "Mira"}}},
{ID: 70, Kind: "speech", Text: "two"},
{ID: 20, Kind: "narration", Text: "three"},
{ID: 90, Kind: "speech", Text: "four"},
}}
candidates := []Candidate{
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 20}}},
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 90, EndUnitID: 90}}},
{Name: "Broken", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 10}}},
}
before := cloneCandidates(candidates)
materials, ready, err := BuildContext(doc, candidates, 1)
if err != nil || !ready {
t.Fatalf("BuildContext() = %#v, %t, %v; want ready materials", materials, ready, err)
}
if !reflect.DeepEqual(candidates, before) {
t.Fatalf("BuildContext() mutated candidates: %#v", candidates)
}
if got, want := materials.CandidateKeys(), []string{"candidate-000001", "candidate-000002", "candidate-000003"}; !reflect.DeepEqual(got, want) {
t.Fatalf("CandidateKeys() = %#v, want %#v", got, want)
}
if got, want := materials.EligibleCandidateKeys(), []string{"candidate-000001", "candidate-000002"}; !reflect.DeepEqual(got, want) {
t.Fatalf("EligibleCandidateKeys() = %#v, want %#v", got, want)
}
if !json.Valid(materials.Candidates.Content) || !json.Valid(materials.Transcript.Content) {
t.Fatalf("prompt materials are not JSON: %#v", materials)
}
if strings.Contains(string(materials.Candidates.Content), doc.ID) {
t.Fatalf("candidate material leaked source identity: %s", materials.Candidates.Content)
}
var candidatePayload candidateInput
if err := json.Unmarshal(materials.Candidates.Content, &candidatePayload); err != nil {
t.Fatal(err)
}
if len(candidatePayload.Candidates) != 2 || candidatePayload.Candidates[0].Name != candidatePayload.Candidates[1].Name || strings.Contains(string(materials.Candidates.Content), "candidate-") {
t.Fatalf("candidate payload = %#v, want contextual descriptors without keys", candidatePayload)
}
if got := candidatePayload.Candidates[0].SourceRefs[0]; got != (SourceRange{StartUnitID: 10, EndUnitID: 20}) {
t.Fatalf("candidate reference = %#v", got)
}
var transcript transcriptInput
if err := json.Unmarshal(materials.Transcript.Content, &transcript); err != nil {
t.Fatal(err)
}
if len(transcript.Windows) != 1 || len(transcript.Windows[0].Units) != 5 {
t.Fatalf("windows = %#v, want one bounded coalesced window", transcript.Windows)
}
units := transcript.Windows[0].Units
for index, wantID := range []int{40, 10, 70, 20, 90} {
if units[index].ID != wantID {
t.Fatalf("window unit %d = %d, want source-order %d", index, units[index].ID, wantID)
}
}
if units[0].Cited || !units[1].Cited || !units[2].Cited || !units[3].Cited || !units[4].Cited {
t.Fatalf("citation flags = %#v", units)
}
windows, err := contextWindows(doc.Units, []sourceInterval{{start: 1, end: 1}}, make([]bool, len(doc.Units)))
if err != nil {
t.Fatal(err)
}
windows[0].Units[0].Metadata["speaker"].(map[string]any)["name"] = "changed"
if doc.Units[1].Metadata["speaker"].(map[string]any)["name"] != "Mira" {
t.Fatal("context metadata aliases source document")
}
}
func TestBuildContextExcludesCollidingDescriptors(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}}}
candidates := []Candidate{
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}},
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}},
{Name: "The Market", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}},
}
materials, ready, err := BuildContext(doc, candidates, 0)
if err != nil || ready || len(materials.EligibleCandidateKeys()) != 1 || strings.Contains(string(materials.Candidates.Content), "The Tavern") {
t.Fatalf("BuildContext() = %#v, %t, %v", materials, ready, err)
}
assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{
Members: []Selector{{Name: "The Tavern", SourceRefs: []SourceRange{{StartUnitID: 1, EndUnitID: 1}}}, {Name: "The Market", SourceRefs: []SourceRange{{StartUnitID: 3, EndUnitID: 3}}}},
Canonical: Selector{Name: "The Market", SourceRefs: []SourceRange{{StartUnitID: 3, EndUnitID: 3}}},
}}})
if !hasIssue(assessment.Issues(), "member_ineligible") {
t.Fatalf("Assess() issues = %#v, want collided descriptor rejection", assessment.Issues())
}
}
func TestAssessmentRejectsPartialAndReorderedDescriptors(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}}
materials, ready, err := BuildContext(doc, []Candidate{
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
{Name: "The Market", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}, 0)
if err != nil || !ready {
t.Fatalf("BuildContext() = %#v, %t, %v", materials, ready, err)
}
selectors := materialSelectors(t, materials)
for _, refs := range [][]SourceRange{
{{StartUnitID: 10, EndUnitID: 10}},
{{StartUnitID: 20, EndUnitID: 20}, {StartUnitID: 10, EndUnitID: 10}},
} {
assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{
Members: []Selector{{Name: "The Tavern", SourceRefs: refs}, selectors[1]},
Canonical: selectors[1],
}}})
if !hasIssue(assessment.Issues(), "member_unknown") {
t.Fatalf("Assess(%#v) issues = %#v, want descriptor mismatch rejection", refs, assessment.Issues())
}
}
}
func TestBuildContextExcludesUnsafeReferencesAndCoalescesAdjacentWindows(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 9}, {ID: 3}, {ID: 8}, {ID: 1}, {ID: 7}}}
candidates := []Candidate{
{Name: "One", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}},
{Name: "Two", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 8, EndUnitID: 8}}},
{Name: "Missing", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 99, EndUnitID: 99}}},
{Name: "Foreign", SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: 1, EndUnitID: 1}}},
{Name: "Blank"},
}
materials, ready, err := BuildContext(doc, candidates, 0)
if err != nil || !ready {
t.Fatalf("BuildContext() error = %v, ready = %t", err, ready)
}
if got, want := materials.EligibleCandidateKeys(), []string{"candidate-000001", "candidate-000002"}; !reflect.DeepEqual(got, want) {
t.Fatalf("EligibleCandidateKeys() = %#v, want %#v", got, want)
}
var transcript transcriptInput
if err := json.Unmarshal(materials.Transcript.Content, &transcript); err != nil {
t.Fatal(err)
}
if len(transcript.Windows) != 1 || len(transcript.Windows[0].Units) != 2 || transcript.Windows[0].Units[0].ID != 3 || transcript.Windows[0].Units[1].ID != 8 {
t.Fatalf("windows = %#v, want adjacent document-order units coalesced", transcript.Windows)
}
if _, ready, err := BuildContext(doc, candidates, -1); err == nil || ready {
t.Fatalf("BuildContext(radius=-1) = ready %t, err %v", ready, err)
}
}
func TestAssessmentRejectsEveryUnsafeProposalCategory(t *testing.T) {
materials := preparedMaterials(t, 4, true)
selectors := materialSelectors(t, materials)
unsafe := []struct {
name string
response ProposalResponse
category string
}{
{"blank member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{{}, selectors[1]}, Canonical: selectors[1]}}}, "member_blank"},
{"unknown member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{{Name: "unknown", SourceRefs: []SourceRange{{StartUnitID: 99, EndUnitID: 99}}}, selectors[1]}, Canonical: selectors[1]}}}, "member_unknown"},
{"repeated member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[0]}, Canonical: selectors[0]}}}, "repeated_member"},
{"too small", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0]}, Canonical: selectors[0]}}}, "fewer_than_two_members"},
{"canonical blank", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[1]}, Canonical: Selector{}}}}, "canonical_blank"},
{"canonical not member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[1]}, Canonical: selectors[2]}}}, "canonical_not_member"},
{"overlapping", ProposalResponse{DuplicateGroups: []DuplicateGroup{
{Members: []Selector{selectors[0], selectors[1]}, Canonical: selectors[0]},
{Members: []Selector{selectors[1], selectors[2]}, Canonical: selectors[2]},
}}, "overlapping_member"},
}
for _, test := range unsafe {
t.Run(test.name, func(t *testing.T) {
assessment := materials.Assess(test.response)
if len(assessment.SafeGroups()) != 0 || assessment.DiscardedGroups() != len(test.response.DuplicateGroups) || !hasIssue(assessment.Issues(), test.category) {
t.Fatalf("Assess() = groups %#v discarded %d issues %#v; want %q rejection", assessment.SafeGroups(), assessment.DiscardedGroups(), assessment.Issues(), test.category)
}
})
}
}
func TestAssessmentReturnsNonOverlappingSafeGroupsAndDefensiveCopies(t *testing.T) {
materials := preparedMaterials(t, 4, false)
keys := materials.CandidateKeys()
selectors := materialSelectors(t, materials)
assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
{Members: []Selector{selectors[1], selectors[0]}, Canonical: selectors[1]},
{Members: []Selector{selectors[3], selectors[2]}, Canonical: selectors[2]},
}})
groups := assessment.SafeGroups()
if assessment.DiscardedGroups() != 0 || len(assessment.Issues()) != 0 || len(groups) != 2 {
t.Fatalf("assessment = %#v, %d, %#v", groups, assessment.DiscardedGroups(), assessment.Issues())
}
if got, want := groups[0].Members(), []string{keys[0], keys[1]}; !reflect.DeepEqual(got, want) || groups[0].Canonical() != keys[1] {
t.Fatalf("first safe group = %#v / %q", got, groups[0].Canonical())
}
keys[0] = "changed"
if materials.CandidateKeys()[0] == "changed" {
t.Fatal("CandidateKeys() exposed retained keys")
}
members := groups[0].Members()
members[0] = "changed"
if groups[0].Members()[0] == "changed" || assessment.SafeGroups()[0].Members()[0] == "changed" {
t.Fatal("SafeGroups() exposed retained members")
}
}
func TestSharedResponseSchemaIsPrivateStrictAndRegisterableOnce(t *testing.T) {
schema, err := LoadResponseSchema()
if err != nil {
t.Fatal(err)
}
if schema.Key != ResponseSchemaKey || schema.ID != ResponseSchemaID || schema.Name != ResponseSchemaName || schema.Version != SchemaVersion || !strings.HasPrefix(schema.SHA256, "sha256:") || !json.Valid(schema.JSONSchema) {
t.Fatalf("schema = %#v", schema)
}
for _, test := range []struct {
name string
value any
valid bool
}{
{"empty groups", map[string]any{"duplicate_groups": []any{}}, true},
{"semantic proposal problem", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{map[string]any{"name": "Mira", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}}}}, "canonical": map[string]any{"name": "Mira", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}}}}}}, true},
{"missing groups", map[string]any{}, false},
{"unknown top level", map[string]any{"duplicate_groups": []any{}, "extra": true}, false},
{"replacement name", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": map[string]any{"name": "Mira", "source_refs": []any{}}, "name": "replacement"}}}, false},
{"missing selector evidence", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": map[string]any{"name": "Mira"}}}}, false},
{"invalid range", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{map[string]any{"name": "Mira", "source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": 1}}}}, "canonical": map[string]any{"name": "Mira", "source_refs": []any{}}}}}, false},
} {
t.Run(test.name, func(t *testing.T) {
content, err := json.Marshal(test.value)
if err != nil {
t.Fatal(err)
}
err = validateSchema(content, schema.JSONSchema)
if (err == nil) != test.valid {
t.Fatalf("validateSchema() error = %v, want valid=%t", err, test.valid)
}
})
}
first := schema.JSONSchema
first[0] = '['
second, err := LoadResponseSchema()
if err != nil || !json.Valid(second.JSONSchema) || bytes.Equal(first, second.JSONSchema) {
t.Fatalf("LoadResponseSchema() returned shared content: %s, %v", second.JSONSchema, err)
}
registry := llm.NewAssetRegistry()
if err := RegisterSchemaAssets(registry); err != nil {
t.Fatalf("RegisterSchemaAssets() error = %v", err)
}
if schemaFS, err := registry.SchemaFS(); err != nil {
t.Fatalf("SchemaFS() error = %v", err)
} else if content, err := fs.ReadFile(schemaFS, "dnd_entity_reconcile_llm.v1.json"); err != nil || !json.Valid(content) {
t.Fatalf("shared schema asset = %s, %v", content, err)
}
}
func preparedMaterials(t *testing.T, count int, includeIneligible bool) Materials {
t.Helper()
doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)}
candidates := make([]Candidate, count)
for index := range candidates {
doc.Units[index] = source.SourceUnit{ID: index + 1, Text: "unit"}
candidates[index] = Candidate{Name: "same display name", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: index + 1, EndUnitID: index + 1}}}
}
if includeIneligible && count > 3 {
candidates[3].SourceRefs = []source.SourceRef{{SourceID: "other", StartUnitID: 1, EndUnitID: 1}}
}
materials, ready, err := BuildContext(doc, candidates, 0)
if err != nil || !ready {
t.Fatalf("BuildContext() = %#v, %t, %v", materials, ready, err)
}
return materials
}
func materialSelectors(t *testing.T, materials Materials) []Selector {
t.Helper()
var input candidateInput
if err := json.Unmarshal(materials.Candidates.Content, &input); err != nil {
t.Fatal(err)
}
return input.Candidates
}
func cloneCandidates(input []Candidate) []Candidate {
output := make([]Candidate, len(input))
copy(output, input)
for index := range output {
output[index].SourceRefs = append([]source.SourceRef(nil), input[index].SourceRefs...)
}
return output
}
func hasIssue(issues []Issue, want string) bool {
for _, issue := range issues {
if issue.Category == want {
return true
}
}
return false
}
func validateSchema(instanceContent, schemaContent []byte) error {
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(instanceContent))
if err != nil {
return err
}
document, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaContent))
if err != nil {
return err
}
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("schema.json", document); err != nil {
return err
}
compiled, err := compiler.Compile("schema.json")
if err != nil {
return err
}
return compiled.Validate(instance)
}

View File

@@ -1,175 +0,0 @@
package entityreconcile
import (
"sort"
"strings"
)
// ProposalResponse is the private structured response exchanged with the
// reconciliation prompt. It identifies candidates by contextual selectors.
type ProposalResponse struct {
DuplicateGroups []DuplicateGroup `json:"duplicate_groups"`
}
// DuplicateGroup proposes contextual candidate descriptors that might denote
// one entity.
type DuplicateGroup struct {
Members []Selector `json:"members"`
Canonical Selector `json:"canonical"`
}
// Issue identifies one unsafe proposal category without prescribing a warning
// message or retry policy to a consuming normalizer.
type Issue struct {
GroupIndex int
Category string
}
// SafeGroup identifies one validated, non-overlapping duplicate group.
type SafeGroup struct {
members []string
canonical string
}
// Members returns an owned copy of the group's candidate keys.
func (g SafeGroup) Members() []string { return append([]string(nil), g.members...) }
// Canonical returns the selected canonical candidate key.
func (g SafeGroup) Canonical() string { return g.canonical }
// Assessment contains only safe groups. Its accessors return owned copies so
// callers cannot mutate retained assessment data.
type Assessment struct {
safeGroups []SafeGroup
discardedGroups int
issues []Issue
}
// SafeGroups returns validated non-overlapping groups in proposal order.
func (a Assessment) SafeGroups() []SafeGroup {
groups := make([]SafeGroup, len(a.safeGroups))
for index, group := range a.safeGroups {
groups[index] = SafeGroup{members: append([]string(nil), group.members...), canonical: group.canonical}
}
return groups
}
// DiscardedGroups returns the number of rejected proposal groups.
func (a Assessment) DiscardedGroups() int { return a.discardedGroups }
// Issues returns the deterministic rejection categories in proposal order.
func (a Assessment) Issues() []Issue { return append([]Issue(nil), a.issues...) }
// Assess resolves contextual descriptors to internal candidate keys, then
// validates the proposal without exposing those keys to the model.
func (m Materials) Assess(response ProposalResponse) Assessment {
groups := make([]assessedGroup, len(response.DuplicateGroups))
issues := make([]Issue, 0)
for groupIndex, proposal := range response.DuplicateGroups {
groups[groupIndex] = m.assessGroup(proposal)
for _, category := range groups[groupIndex].issues {
issues = append(issues, Issue{GroupIndex: groupIndex, Category: category})
}
}
owners := make(map[string][]int)
for groupIndex, group := range groups {
if !group.locallyValid {
continue
}
for _, key := range group.members {
owners[key] = append(owners[key], groupIndex)
}
}
for groupIndex := range groups {
if !groups[groupIndex].locallyValid {
continue
}
for _, key := range groups[groupIndex].members {
if len(owners[key]) > 1 {
groups[groupIndex].conflicting = true
issues = append(issues, Issue{GroupIndex: groupIndex, Category: "overlapping_member"})
break
}
}
}
assessment := Assessment{issues: issues}
for _, group := range groups {
if !group.locallyValid || group.conflicting {
assessment.discardedGroups++
continue
}
assessment.safeGroups = append(assessment.safeGroups, SafeGroup{members: append([]string(nil), group.members...), canonical: group.canonical})
}
return assessment
}
type assessedGroup struct {
members []string
canonical string
issues []string
locallyValid bool
conflicting bool
}
func (m Materials) assessGroup(proposal DuplicateGroup) assessedGroup {
issues := make([]string, 0)
members := make([]string, 0, len(proposal.Members))
seen := make(map[string]struct{}, len(proposal.Members))
for _, selector := range proposal.Members {
key, category := m.selectorKey(selector)
if category != "" {
issues = append(issues, "member_"+category)
continue
}
if _, exists := seen[key]; exists {
issues = append(issues, "repeated_member")
continue
}
seen[key] = struct{}{}
members = append(members, key)
}
canonical, canonicalCategory := m.selectorKey(proposal.Canonical)
if canonicalCategory != "" {
issues = append(issues, "canonical_"+canonicalCategory)
}
if len(members) < 2 {
issues = append(issues, "fewer_than_two_members")
}
if canonicalCategory == "" && !contains(members, canonical) {
issues = append(issues, "canonical_not_member")
}
sort.Strings(members)
return assessedGroup{members: members, canonical: canonical, issues: issues, locallyValid: len(issues) == 0}
}
func (m Materials) selectorKey(selector Selector) (string, string) {
if strings.TrimSpace(selector.Name) == "" {
return "", "blank"
}
lookupKey, err := selectorLookupKey(selector)
if err != nil {
return "", "unknown"
}
if _, collided := m.collidedSelectors[lookupKey]; collided {
return "", "ineligible"
}
key, ok := m.keyBySelector[lookupKey]
if !ok {
return "", "unknown"
}
if _, eligible := m.eligible[key]; !eligible {
return "", "ineligible"
}
return key, ""
}
func contains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}

View File

@@ -1,55 +0,0 @@
package entityreconcile
import (
"fmt"
"io/fs"
rootassets "gitea.maximumdirect.net/eric/notarius/assets"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
)
const (
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_entity_reconcile_llm")
ResponseSchemaID = "notarius.dnd.entity_reconcile.llm"
ResponseSchemaName = "notarius_dnd_entity_reconcile_llm_v1"
SchemaVersion = "v1"
SchemaAssetPath = "schemas/dnd_entity_reconcile_llm.v1.json"
)
func schemaAssetFS() (fs.FS, error) {
assets, err := fs.Sub(rootassets.FS(), "dnd/entity-reconciliation")
if err != nil {
return nil, fmt.Errorf("scope entity reconciliation assets: %w", err)
}
return assets, nil
}
// LoadResponseSchema returns the shared private duplicate-group response
// contract. It is intentionally separate from durable artifact schemas.
func LoadResponseSchema() (llm.ResponseSchema, error) {
assets, err := schemaAssetFS()
if err != nil {
return llm.ResponseSchema{}, err
}
return llm.LoadResponseSchema(assets, llm.ResponseSchemaDefinition{
Key: ResponseSchemaKey,
ID: ResponseSchemaID,
Version: SchemaVersion,
Name: ResponseSchemaName,
AssetPath: SchemaAssetPath,
})
}
// RegisterSchemaAssets makes the shared private response schema available to
// prompt preparation. A family registrar can register it once for all consumers.
func RegisterSchemaAssets(registry *llm.AssetRegistry) error {
assets, err := schemaAssetFS()
if err != nil {
return err
}
schemas, err := fs.Sub(assets, "schemas")
if err != nil {
return fmt.Errorf("scope entity reconciliation schemas: %w", err)
}
return registry.RegisterSchemaFS(schemas, ".")
}

View File

@@ -6,6 +6,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/chunk/units"
jsonoutput "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/output/json"
alwaysaccept "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/always_accept"
@@ -16,14 +17,17 @@ import (
// Register adds all production domain-neutral modules and validators.
func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error {
_ = assets
if err := validateRegistries(registries); err != nil {
return err
}
if assets == nil {
return fmt.Errorf("generic registrar: asset registry must not be nil")
}
registrations := []struct {
name string
register func() error
}{
{name: "semantic reconciliation assets", register: func() error { return semanticreconcile.RegisterAssets(assets) }},
{name: "generic chunker", register: func() error { return units.Register(registries.Chunkers) }},
{name: "always accept validator", register: func() error { return alwaysaccept.Register(registries.Validators) }},
{name: "always reject validator", register: func() error { return alwaysreject.Register(registries.Validators) }},

View File

@@ -1,15 +1,18 @@
package register
import (
"io/fs"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestRegisterAddsGenericFamily(t *testing.T) {
registries := completeRegistries()
if err := Register(registries, nil); err != nil {
assets := llm.NewAssetRegistry()
if err := Register(registries, assets); err != nil {
t.Fatalf("Register() error = %v, want nil", err)
}
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"generic"})
@@ -30,6 +33,31 @@ func TestRegisterAddsGenericFamily(t *testing.T) {
if output, err := registries.Outputs.Build("json"); err != nil || output.Key() != "json" {
t.Fatalf("build json output = %v, %v; want json implementation", output, err)
}
promptAssets, err := assets.PromptFS()
if err != nil {
t.Fatal(err)
}
if _, err := fs.ReadFile(promptAssets, "generic.semantic_reconciliation/prompt.yaml"); err != nil {
t.Fatalf("registered semantic reconciliation prompt: %v", err)
}
schemaAssets, err := assets.SchemaFS()
if err != nil {
t.Fatal(err)
}
if _, err := fs.ReadFile(schemaAssets, "semantic_reconciliation_llm.v1.json"); err != nil {
t.Fatalf("registered semantic reconciliation schema: %v", err)
}
}
func TestRegisterRejectsNilAssetRegistryBeforeMutation(t *testing.T) {
registries := completeRegistries()
err := Register(registries, nil)
if err == nil || !strings.Contains(err.Error(), "asset registry must not be nil") {
t.Fatalf("Register() error = %v, want nil asset registry error", err)
}
if len(registries.Chunkers.RegisteredKeys()) != 0 {
t.Fatalf("chunker keys = %#v, want validation before mutation", registries.Chunkers.RegisteredKeys())
}
}
func TestRegisterRejectsMissingGenericRegistriesBeforeMutation(t *testing.T) {
@@ -48,7 +76,7 @@ func TestRegisterRejectsMissingGenericRegistriesBeforeMutation(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
registries := completeRegistries()
test.remove(&registries)
err := Register(registries, nil)
err := Register(registries, llm.NewAssetRegistry())
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("Register() error = %v, want %q", err, test.wantErr)
}
@@ -61,11 +89,12 @@ func TestRegisterRejectsMissingGenericRegistriesBeforeMutation(t *testing.T) {
func TestRegisterReportsDuplicateGenericRegistration(t *testing.T) {
registries := completeRegistries()
if err := Register(registries, nil); err != nil {
assets := llm.NewAssetRegistry()
if err := Register(registries, assets); err != nil {
t.Fatalf("first Register() error = %v, want nil", err)
}
err := Register(registries, nil)
if err == nil || !strings.Contains(err.Error(), "register generic chunker") || !strings.Contains(err.Error(), "already registered") {
err := Register(registries, assets)
if err == nil || !strings.Contains(err.Error(), "register semantic reconciliation assets") || !strings.Contains(err.Error(), "already registered") {
t.Fatalf("second Register() error = %v, want contextual duplicate error", err)
}
}

View File

@@ -17,6 +17,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/evidencecontext"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry"
@@ -51,8 +52,8 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
for name, value := range map[string]string{
"extract:npc_registry:dnd/npc-registry:mapping_policy": "dnd.npc_registry.extract_mapping.v2",
"normalize:npc_registry:dnd/npc-registry:identity_policy": "dnd.npc_registry.identity.v1",
"normalize:npc_registry:dnd/npc-registry:normalization_policy": "dnd.npc_registry.normalize.v4",
"normalize:npc_registry:dnd/npc-registry:semantic_context_policy": "dnd.entity_reconcile.context.v1:2",
"normalize:npc_registry:dnd/npc-registry:normalization_policy": npcnormalize.NormalizationPolicy,
"normalize:npc_registry:dnd/npc-registry:semantic_reconciliation_policy": semanticreconcile.Policy,
"extract:spells:dnd/spells:mapping_policy": "dnd.spells.extract_mapping.v2",
"extract:combat:dnd/combat-turns:scene_gate_policy": "dnd.combat_turns.scene_gate.v1",
} {
@@ -63,6 +64,7 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
"extract:npc_registry:dnd/npc-registry:response_schema",
"normalize:npc_registry:dnd/npc-registry:prompt",
"normalize:npc_registry:dnd/npc-registry:response_schema",
"normalize:npc_registry:dnd/npc-registry:semantic_reconciliation_limits",
"extract:spells:dnd/spells:prompt",
"extract:spells:dnd/spells:response_schema",
"extract:spells:dnd/spells:npc_registry",

View File

@@ -292,10 +292,7 @@ func (client *semanticNPCOccurrenceClient) CompleteStructured(_ context.Context,
}
payload = map[string]any{"npcs": []any{map[string]any{"name": name, "source_refs": []any{map[string]int{"start_unit_id": client.npcCalls, "end_unit_id": client.npcCalls}}}}}
case npcnormalize.PromptID:
content, err := contextualReconciliationContent([]byte(`{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`), request.Inputs["candidates"].Content)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
content := []byte(`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1}]}`)
if err := json.Unmarshal(content, out); err != nil {
return contracts.StructuredCompletionResponse{}, err
}

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"testing"
@@ -13,12 +12,12 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcregistry"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcregistry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
dndregister "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/register"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcregistry/shape"
npcregistrysourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcregistry/source_refs"
genericregister "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/register"
@@ -98,7 +97,7 @@ func TestRunnerProcessesSeriatimInputWithProductionDNDNPCPipeline(t *testing.T)
t.Fatalf("manifest lane = %#v, want NPC production composition", lane)
}
normalizerMetadata, ok := lane.Metadata["normalizer"].(map[string]any)
if !ok || normalizerMetadata["identity_policy"] != identity.Policy || normalizerMetadata["normalization_policy"] != npcnormalize.NormalizationPolicy || normalizerMetadata["prompt_id"] != npcnormalize.PromptID || normalizerMetadata["response_schema_id"] != entityreconcile.ResponseSchemaID {
if !ok || normalizerMetadata["identity_policy"] != identity.Policy || normalizerMetadata["normalization_policy"] != npcnormalize.NormalizationPolicy || normalizerMetadata["prompt_id"] != npcnormalize.PromptID || normalizerMetadata["response_schema_id"] != semanticreconcile.ResponseSchemaID {
t.Fatalf("normalizer metadata = %#v, want identity and normalization policies", lane.Metadata)
}
var npcOutputFile *contracts.OutputFile
@@ -180,8 +179,8 @@ func TestProductionNPCNormalizationRetryUsesFinalSafeProposal(t *testing.T) {
{Name: "Mira Thorn", SourceRefs: []npcProductionSourceRef{{StartUnitID: 2, EndUnitID: 2}}},
{Name: "Hooded Guard", SourceRefs: []npcProductionSourceRef{{StartUnitID: 3, EndUnitID: 3}}},
}}
partial := []byte(`{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"},{"members":["candidate-000003","unknown"],"canonical":"candidate-000003"}]}`)
safe := []byte(`{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`)
partial := []byte(`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2},{"candidate_ids":[3,99],"canonical_candidate_id":3}]}`)
safe := []byte(`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`)
for _, test := range []struct {
name string
@@ -267,11 +266,6 @@ func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context,
}
content = append([]byte(nil), client.normalizeResponses[index]...)
}
var err error
content, err = contextualReconciliationContent(content, req.Inputs["candidates"].Content)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected fake NPC prompt %q", req.PromptID)
}
@@ -281,43 +275,6 @@ func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context,
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func contextualReconciliationContent(content, candidateContent []byte) ([]byte, error) {
if !strings.Contains(string(content), "candidate-") {
return content, nil
}
var selection struct {
DuplicateGroups []struct {
Members []string `json:"members"`
Canonical string `json:"canonical"`
} `json:"duplicate_groups"`
}
if err := json.Unmarshal(content, &selection); err != nil {
return nil, err
}
var candidates struct {
Candidates []entityreconcile.Selector `json:"candidates"`
}
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
return nil, err
}
selector := func(key string) entityreconcile.Selector {
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
if err != nil || index < 1 || index > len(candidates.Candidates) {
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
}
return candidates.Candidates[index-1].Clone()
}
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
for index, group := range selection.DuplicateGroups {
members := make([]entityreconcile.Selector, len(group.Members))
for memberIndex, key := range group.Members {
members[memberIndex] = selector(key)
}
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
}
return json.Marshal(proposal)
}
func (client *fakeNPCProductionLLMClient) requestCount(promptID string) int {
count := 0
for _, request := range client.requests {