Compare commits

18 Commits

Author SHA1 Message Date
bef8ca263b Tighten evidence context source excerpts 2026-08-09 20:04:46 +00:00
f6d037b613 Document evidence context source unit excerpts 2026-08-09 19:47:27 +00:00
449b506804 Update evidence context output coverage 2026-08-09 19:45:21 +00:00
071a78ae22 Replace evidence context with source unit excerpts 2026-08-09 19:42:33 +00:00
ad1cba41c2 Fix OpenAI reconciliation schema 2026-08-09 18:49:29 +00:00
67338798aa Complete the semantic reconciliation roadmap 2026-08-09 18:30:32 +00:00
e95e2f2220 Finalize semantic reconciliation documentation 2026-08-09 17:05:17 +00:00
628b8d1800 Remove legacy reconciliation path 2026-08-09 16:57:44 +00:00
d24d4609b6 Migrate location reconciliation to shared engine 2026-08-09 16:51:29 +00:00
c7f79fb38e Migrate item reconciliation to shared engine 2026-08-09 16:46:43 +00:00
8c071800cf Migrate NPC reconciliation to shared engine 2026-08-09 16:38:43 +00:00
569e12c6f4 Add generic reconciliation plan application 2026-08-09 16:27:36 +00:00
5b6eb591b2 Add shared semantic reconciliation engine 2026-08-09 16:19:54 +00:00
b630384aa0 Add generic semantic reconciliation prompt assets 2026-08-09 16:08:53 +00:00
297d58f090 Add semantic reconciliation proposal validation 2026-08-09 16:00:01 +00:00
ee71dc4937 Add bounded semantic candidate preparation 2026-08-09 15:52:55 +00:00
65e5d65d14 Record semantic reconciliation architecture decision 2026-08-09 15:43:05 +00:00
b40b40aaf3 Plan semantic reconciliation improvements 2026-08-09 15:38:10 +00:00
89 changed files with 4748 additions and 2479 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 Determine whether candidates identify the same item type or unique designation
candidates identify the same item type or unique designation. Do not treat using their contextual labels and cited transcript windows. Do not treat nearby
nearby evidence, similar objects, or a shared owner as sufficient. Keep evidence, similar objects, or a shared owner as sufficient.
currency denominations, materially different item types, and uncertain aliases
separate. Do not infer an item property or uniqueness. 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 When selecting a canonical display name, choose one supplied candidate name
that is the clearest established designation. that is the clearest established designation.

View File

@@ -12,19 +12,19 @@ messages:
- role: system - role: system
content_file: ./sharedassets/common-dnd-system.md content_file: ./sharedassets/common-dnd-system.md
- role: user - role: user
content_file: ./instructions.md content_file: ./sharedassets/protocol.md
- role: user - role: user
content_file: ./sharedassets/common-dnd-entity-reconciliation.md content_file: ./instructions.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user - role: user
content_file: ./candidates.md content_file: ./sharedassets/candidates.md
- role: user - role: user
content_file: ./sharedassets/common-dnd-transcript-windows.md content_file: ./sharedassets/transcript-windows.md
cache_control: cache_control:
type: ephemeral type: ephemeral
output: output:
format: json format: json
validation_mode: json_schema validation_mode: json_schema
schema_path: dnd_entity_reconcile_llm.v1.json schema_path: semantic_reconciliation_llm.v1.json
repair_attempts: 0 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 Determine whether candidates identify the same physical place using their
candidates identify the same physical place. Do not treat matching names, contextual labels and cited transcript windows. Do not treat matching names,
nearby evidence, nested places, or generic labels as sufficient. Keep parent nearby evidence, nested places, or generic labels as sufficient.
and child places, similarly named places, and uncertain aliases separate.
Keep parent and child places separate, as well as similarly named places and
uncertain aliases.
When selecting a canonical display name, prefer the clearest established name. When selecting a canonical display name, prefer the clearest established name.

View File

@@ -12,19 +12,19 @@ messages:
- role: system - role: system
content_file: ./sharedassets/common-dnd-system.md content_file: ./sharedassets/common-dnd-system.md
- role: user - role: user
content_file: ./instructions.md content_file: ./sharedassets/protocol.md
- role: user - role: user
content_file: ./sharedassets/common-dnd-entity-reconciliation.md content_file: ./instructions.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user - role: user
content_file: ./candidates.md content_file: ./sharedassets/candidates.md
- role: user - role: user
content_file: ./sharedassets/common-dnd-transcript-windows.md content_file: ./sharedassets/transcript-windows.md
cache_control: cache_control:
type: ephemeral type: ephemeral
output: output:
format: json format: json
validation_mode: json_schema validation_mode: json_schema
schema_path: dnd_entity_reconcile_llm.v1.json schema_path: semantic_reconciliation_llm.v1.json
repair_attempts: 0 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 Determine whether candidates refer to the same individual using their
candidates refer to the same individual. Preserve distinct individuals even contextual labels and cited transcript windows. Preserve distinct individuals
when their names are similar. even when their names are similar or their contextual descriptions are
identical.
When selecting a canonical display name, prefer a complete, stable proper name When selecting a canonical display name, prefer a complete, stable proper name
over an abbreviation. Prefer an unadorned proper name over that name plus a over an abbreviation. Prefer an unadorned proper name over that name plus a

View File

@@ -12,19 +12,19 @@ messages:
- role: system - role: system
content_file: ./sharedassets/common-dnd-system.md content_file: ./sharedassets/common-dnd-system.md
- role: user - role: user
content_file: ./instructions.md content_file: ./sharedassets/protocol.md
- role: user - role: user
content_file: ./sharedassets/common-dnd-entity-reconciliation.md content_file: ./instructions.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user - role: user
content_file: ./candidates.md content_file: ./sharedassets/candidates.md
- role: user - role: user
content_file: ./sharedassets/common-dnd-transcript-windows.md content_file: ./sharedassets/transcript-windows.md
cache_control: cache_control:
type: ephemeral type: ephemeral
output: output:
format: json format: json
validation_mode: json_schema validation_mode: json_schema
schema_path: dnd_entity_reconcile_llm.v1.json schema_path: semantic_reconciliation_llm.v1.json
repair_attempts: 0 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" }} {{ 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,32 @@
{
"$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,
"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

@@ -319,7 +319,8 @@ Unknown outer or nested option fields are rejected, as are incompatible YAML
types. The allowlist remains valid when a run uses lane filtering: a configured types. The allowlist remains valid when a run uses lane filtering: a configured
lane that is not active for that invocation simply contributes no evidence. lane that is not active for that invocation simply contributes no evidence.
Evidence publication is opt-in because it can persist source text and metadata. Evidence publication is opt-in because it can persist source text and metadata.
Its payload contract is [Published Evidence Context](integrations/evidence-context.md). When enabled, it publishes the selected source-unit excerpt defined by the
[Published Evidence Context contract](integrations/evidence-context.md).
## References And Ordered Handoffs ## References And Ordered Handoffs

View File

@@ -55,10 +55,10 @@ contract. The JSON bundle contract links to the available lane contracts.
If `index.json` has an `evidence_context` descriptor, treat it as a If `index.json` has an `evidence_context` descriptor, treat it as a
pipeline-wide artifact rather than a lane entry. Verify its six descriptor pipeline-wide artifact rather than a lane entry. Verify its six descriptor
fields before decoding the linked file according to the [Published Evidence fields before decoding the linked file according to the [Published Evidence
Context contract](../integrations/evidence-context.md). Use each Context contract](../integrations/evidence-context.md). Decode its top-level
`evidence_refs` entry as the citation to source material. Its surrounding source-unit array as a reading excerpt. Obtain authoritative citations and lane
context range and included units explain the citation, but do not widen or provenance from the normalized lane artifacts; the excerpt has neither and its
replace the cited source reference. nearby units do not widen a lane artifact's cited source reference.
A zero exit status may still report rejected outputs, warnings, or absent A zero exit status may still report rejected outputs, warnings, or absent
lanes. The caller decides which lane IDs are required for its own work and lanes. The caller decides which lane IDs are required for its own work and
@@ -73,5 +73,5 @@ them. Treat the input, output bundle, cache, debug bundle, and captured process
logs as potentially sensitive data. Apply the caller's access controls and logs as potentially sensitive data. Apply the caller's access controls and
retention policy, and avoid copying secrets into arguments, logs, or retention policy, and avoid copying secrets into arguments, logs, or
provenance records. An evidence-context artifact contains source-unit text and provenance records. An evidence-context artifact contains source-unit text and
metadata, and selected lanes can cover most of an input; preserve and share it metadata and can cover most of an input; preserve and share it only when that
only when that source content is authorized for the recipient. source content is authorized for the recipient.

View File

@@ -1,9 +1,11 @@
# Published Evidence Context # Published Evidence Context
This contract defines the optional `source/evidence-context` artifact emitted This contract defines the optional `source/evidence-context` artifact emitted
by the production JSON output. Its configuration is owned by by the production JSON output. It is a selected source-unit excerpt for
[Configuration](../config.md#module-bindings-and-validators); its logical-file convenient reading alongside normalized lane artifacts; it is not a second
discovery is owned by [Published JSON Output](json-output.md). citation or provenance model. Its configuration is owned by
[Configuration](../config.md#module-bindings-and-validators), and its
logical-file discovery is owned by [Published JSON Output](json-output.md).
## Identity And Discovery ## Identity And Discovery
@@ -26,91 +28,80 @@ its absence means evidence publication was not enabled for that bundle.
## Payload ## Payload
The v1 payload is a JSON object with required `source_id`, `source_digest`, The v1 payload is a top-level JSON array of generic source units. There is no
`window_units`, `selected_lanes`, and `contexts` fields. `selected_lanes` and wrapper, source-level metadata, context grouping, lane identifier, or evidence
`contexts` are always arrays; an enabled configuration with no accepted direct reference in the payload. An enabled configuration with no contributing
evidence publishes `contexts: []`. accepted evidence publishes `[]`.
```json ```json
{ [
"source_id": "session-alpha", {
"source_digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "id": 10,
"window_units": 1, "kind": "transcript_segment",
"selected_lanes": ["npc_registry", "spells"], "text": "Aria casts Cure Wounds.",
"contexts": [ "ref": {
{ "source_id": "session-alpha",
"context_ref": { "start_unit_id": 10,
"source_id": "session-alpha", "end_unit_id": 10
"start_unit_id": 10,
"end_unit_id": 20
},
"evidence_refs": [
{
"lane_id": "spells",
"source_ref": {
"source_id": "session-alpha",
"start_unit_id": 10,
"end_unit_id": 10
}
}
],
"units": [
{
"id": 10,
"kind": "transcript_segment",
"text": "Aria casts Cure Wounds.",
"ref": {
"source_id": "session-alpha",
"start_unit_id": 10,
"end_unit_id": 10
}
},
{
"id": 20,
"kind": "transcript_segment",
"text": "The party regroups.",
"ref": {
"source_id": "session-alpha",
"start_unit_id": 20,
"end_unit_id": 20
}
}
]
} }
] },
} {
"id": 20,
"kind": "transcript_segment",
"text": "The party regroups.",
"ref": {
"source_id": "session-alpha",
"start_unit_id": 20,
"end_unit_id": 20
}
}
]
``` ```
Each context requires a `context_ref` object and `evidence_refs` and `units` Each source unit has required `id`, `kind`, `text`, and self `ref` fields.
arrays. `context_ref` identifies the first and last included unit. Each `ref` contains `source_id`, `start_unit_id`, and `end_unit_id`, and both unit
evidence entry contains a selected `lane_id` and an original `source_ref`. A endpoints identify that unit's `id`. A unit may also contain source-owned
unit uses the existing source-unit shape: required `id`, `kind`, `text`, and `metadata`, an open-ended JSON object. Fixed unit and reference fields are
self `ref`, plus optional JSON-object `metadata`. Fixed payload objects reject strict: consumers must reject unknown fixed fields, malformed units, invalid
unknown fields; unit metadata may contain application-defined JSON values. self-references, units whose `source_id` differs from other units in the same
excerpt, and a payload that is not the array described here.
## Citations And Context The excerpt preserves each selected unit exactly as represented by the
validated generic source document. It does not add evidence-context-specific
annotations or reshape source-owned metadata.
`evidence_refs` are the authoritative citations. They identify the direct ## Selection And Citations
references emitted by accepted normalized artifacts. `context_ref` and the
units collection include those cited units plus nearby source units selected by
the configured window. They are explanatory context, not widened citations.
Only accepted outputs from the configured lane allowlist contribute. Rejected, The framework obtains direct source references only through typed evidence
failed, absent, and lane-filtered outputs do not contribute. The artifact never projections of accepted normalized artifacts in the configured lane allowlist.
contains raw input bytes, prompts, model responses, auxiliary reference It validates each reference against the current source document, expands its
content, credentials, or filesystem paths. range by `window_units` source-unit positions on each side, clamps at document
boundaries, and takes the union of all expanded ranges. The output contains
each selected source unit once in source-document position order, regardless
of numeric unit IDs. Repeated references, overlapping windows, and citations
from multiple lanes do not duplicate a unit. Rejected, failed, absent,
inactive, and unselected lanes contribute nothing.
## Ordering And Compatibility Normalized lane artifacts remain authoritative for citations and for which lane
cited a range. The excerpt has no lane attribution and must not be used to
reconstruct it. Its included nearby units provide reading context only; they
do not widen any citation in a lane artifact.
The selected lane allowlist is lexical. Contexts and units are in source The excerpt contains at most every generic source unit once. It can therefore
document position order, not numeric unit-ID order. Direct evidence entries equal the complete generic source document when coverage is broad or the
are deterministically ordered by lane and source reference. Overlapping or window is large. No byte-, token-, or compression-size guarantee is made, and
contiguous windows merge, and each source unit appears at most once in the the framework does not truncate the excerpt to meet an arbitrary size limit.
resulting contexts.
## Consumer Responsibilities And Data Handling
The artifact is additive to the JSON bundle and is not a lane payload, The artifact is additive to the JSON bundle and is not a lane payload,
normalized-output count, checkpoint, or generated reference. Consumers that normalized-output count, checkpoint, or generated reference. Consumers that
do not need it must tolerate the absent optional descriptor. Consumers that do do not need it must tolerate an absent descriptor. Consumers that do use it
use it should preserve the artifact and its schema identity with the run should validate the descriptor and payload before use, retain the artifact with
provenance, and should treat its source text and metadata as sensitive durable its schema identity when needed for a run record, and read citations from the
content. corresponding normalized lane artifacts.
The excerpt contains source-unit text and source-owned metadata and is durable
output. Treat it as sensitive source content, apply appropriate access controls
and retention, and do not assume its selected form is materially smaller or
less sensitive than the original input.

View File

@@ -24,7 +24,7 @@ root for the logical discovery described here.
| `warnings.json` | Accepted-output and run warnings. | | `warnings.json` | Accepted-output and run warnings. |
| `lanes/<safe-lane-id>.json` | One normalized artifact payload for each lane. | | `lanes/<safe-lane-id>.json` | One normalized artifact payload for each lane. |
| `chunk-map.json` | Optional accepted chunk map, when its export is enabled and available. | | `chunk-map.json` | Optional accepted chunk map, when its export is enabled and available. |
| `evidence-context.json` | Optional source-context artifact, when evidence publication is enabled. | | `evidence-context.json` | Optional selected source-unit excerpt, when evidence publication is enabled. |
JSON files are pretty-printed with a trailing newline. Lane payloads are JSON files are pretty-printed with a trailing newline. Lane payloads are
accepted only when their media type is `application/json`. accepted only when their media type is `application/json`.

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 source. Deployment profile selection is documented in
[Configuration](../config.md#promptkit-profiles). [Configuration](../config.md#promptkit-profiles).
The transcript assets have distinct consumers. Scene chunking consumes the The D&D transcript assets have distinct consumers. Scene chunking consumes the
complete-session `common-dnd-transcript-full.md`; extraction prompts consume complete-session `common-dnd-transcript-full.md`, while extraction prompts
the current-chunk `common-dnd-transcript-chunk.md`; and NPC, location, and item consume the current-chunk `common-dnd-transcript-chunk.md`. NPC, location, and
normalization consume `common-dnd-transcript-windows.md` alongside their item normalization instead mount the generic semantic-reconciliation
candidate collections. Player, party, glossary, and compatible campaign candidate and transcript-window presentation assets. Player, party, glossary,
references provide disambiguating context only when declared by the active and compatible campaign references provide disambiguating context only when
prompt; they never establish evidence. Reference material is canonically declared by the active prompt; they never establish evidence. Reference
ordered before rendering so equivalent inputs remain stable. material is canonically ordered before rendering so equivalent inputs remain
stable.
Extraction prompts render the common system and identity messages first, then Extraction prompts render the common system and identity messages first, then
cached campaign references and the cached chunk transcript. Evidence policy and 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 Scene chunking intentionally uses a different order: system, cached campaign
references, uncached module instructions, then the final ephemeral full references, uncached module instructions, then the final ephemeral full
transcript. Entity normalization also has its own order: system, uncached transcript. Entity normalization also has its own order: D&D system, mandatory
module instructions, ephemeral reconciliation policy, uncached candidates, and generic protocol, ephemeral domain semantic instructions, generic candidate
final ephemeral transcript windows. These orders and cache controls are prompt presentation, and final ephemeral generic transcript windows. These orders and
behavior; change them only through the owning manifest and prompt declaration. cache controls are prompt behavior; change them only through the owning
manifest and prompt declaration.
## Evidence, Candidates, And Normalization ## 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, output, and issue bounded warnings for changes or collapsed duplicates. NPC,
item, and location registry normalizers are intentional exceptions: each first item, and location registry normalizers are intentional exceptions: each first
produces a deterministic candidate set, then may use a bounded structured-LLM produces a deterministic candidate set, then may use a bounded structured-LLM
proposal to reconcile identity groups. The proposal selects supplied proposal to reconcile identity groups.
descriptors—names with their candidate source references—not durable IDs.
Request-local candidate keys may support resolution internally, but are never ## Semantic Registry Reconciliation
included in model input or output. Colliding descriptors are ineligible, and
invalid or unusable proposals retain the deterministic result with retry or The three registry normalizers instantiate the domain-neutral
fallback diagnostics; the model does not directly replace durable records. `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 ## 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 [ADR-0011](../adr/0011-centralize-llm-assets.md), rather than by this runtime
guide. 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 Mounted prompt assets determine a module's fingerprint. The fingerprint hashes
only the module and shared files explicitly selected by its manifest, so an only the module and shared files explicitly selected by its manifest, so an
unrelated asset does not invalidate a checkpoint. Schema loaders validate JSON, unrelated asset does not invalidate a checkpoint. Schema loaders validate JSON,
attach identity and digest metadata, make defensive copies, and expose attach identity and digest metadata, make defensive copies, and expose
diagnostics without raw schema bytes. 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 Private response schemas validate a model transport envelope. They are not the
durable artifact schema and should not be documented as an external wire durable artifact schema and should not be documented as an external wire
contract. Durable formats and compatibility rules remain in the contract. Durable formats and compatibility rules remain in the

View File

@@ -42,14 +42,23 @@ generic source references and must use the codec's exact Go type. It does not
interpret surrounding context or publish files; the pipeline validates the interpret surrounding context or publish files; the pipeline validates the
capability during preparation and the output boundary owns publication. See capability during preparation and the output boundary owns publication. See
the [Published Evidence Context contract](../integrations/evidence-context.md) the [Published Evidence Context contract](../integrations/evidence-context.md)
for the durable result. for the durable source-unit excerpt. Lane artifacts retain citation and lane
provenance; the framework does not add either to that published excerpt.
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
Production composition is intentionally split by family: Production composition is intentionally split by family:
- The generic registrar provides the unit chunker, generic JSON validators, - 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 - The Seriatim registrar provides the transcript input adapter. Its external
input behavior is defined by the [Seriatim contract](../integrations/seriatim.md). input behavior is defined by the [Seriatim contract](../integrations/seriatim.md).
- The D&D registrar provides its codecs, extractors, mergers, normalizers, - The D&D registrar provides its codecs, extractors, mergers, normalizers,
@@ -60,6 +69,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 may register its own family but must not assemble the CLI or make framework
packages depend on production extensions. 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 ## Adding Or Changing A Module
1. Choose the pipeline stage and the typed artifact boundary. Put external 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. | | 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. | | 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. | | 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). | | 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. | | 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. | | 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. composition, and path safety.
- [LLM Runtime](llm.md): structured completion, scheduling, prompt assets, - [LLM Runtime](llm.md): structured completion, scheduling, prompt assets,
profiles, and secret handling. profiles, and secret handling.
- [Module Internals](modules.md): generic extension registration, module - [Module Internals](modules.md): generic extension registration, artifact
construction, validation, and reference mechanics. families, module construction, semantic reconciliation, validation, and
reference mechanics.
- [D&D Module Internals](dnd.md): shared D&D extractor conventions, generated - [D&D Module Internals](dnd.md): shared D&D extractor conventions, generated
reference projections, and lane-specific exceptions. Durable D&D and reference projections, and lane-specific exceptions. Durable D&D and
Seriatim data shapes remain in the [integration contracts](../integrations/). Seriatim data shapes remain in the [integration contracts](../integrations/).

View File

@@ -110,9 +110,9 @@ are defined in [Accepted Chunk Map](integrations/chunk-map.md). An optional
[evidence context](integrations/evidence-context.md) contains source-unit text [evidence context](integrations/evidence-context.md) contains source-unit text
and metadata. It is not a cache or debug artifact: retain it with the output and metadata. It is not a cache or debug artifact: retain it with the output
bundle only for as long as consumers need it, and apply source-content access bundle only for as long as consumers need it, and apply source-content access
controls to the entire bundle. Selected lanes may collectively cite most of a controls to the entire bundle. Its selected source-unit excerpt may include
transcript, so a broad allowlist can make the evidence artifact nearly as every source unit once when coverage is broad or its configured window is
sensitive and large as the source itself. large, so do not assume a byte or token reduction or reduced sensitivity.
## Chunk-Plan Cache ## Chunk-Plan Cache

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 chunking, merging, or normalization behavior must not be hidden inside an
extractor. 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 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 extract, merge, and normalize stages, and the output stage aggregates the run's
lane outcomes. lane outcomes.
@@ -39,6 +45,12 @@ implementations. Domain-neutral model and framework layers provide reusable
policy, contracts, and orchestration. Concrete input, pipeline, output, and policy, contracts, and orchestration. Concrete input, pipeline, output, and
validation extensions depend inward on those generic layers. 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 Generic layers must not depend on production extensions. Concrete extensions
must not compose the application or take ownership of process behavior. The must not compose the application or take ownership of process behavior. The
current packages implementing these layers are inventoried in 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 When a model selects an application entity, callers must supply a contextual
selection and deterministically attach the opaque application identity whenever selection and deterministically attach the opaque application identity whenever
the selection resolves exactly. Models do not receive or reproduce opaque the selection resolves exactly. Models do not receive or reproduce opaque
application identifiers; [ADR-0012](../adr/0012-resolve-opaque-entity-identifiers-deterministically.md) application identifiers. Semantic reconciliation may instead expose
records the rationale and limited request-local-label exception. 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 LLM calls and other external operations accept cancellation and respect
timeouts. Concurrency control belongs in shared runtime plumbing rather than in 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 ## 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 ### Large-Collection Semantic Reconciliation
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.
The model may use its own domain knowledge to judge semantic duplication; the - Evaluate deterministic candidate blocking only after representative registry
generic implementation is responsible only for the common proposal contract, inputs exceed the active roadmap's bounded single-request limits. Blocking
safety checks, and deterministic application of accepted changes. 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 ### 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 producer or all dependents must be recomputed. Do not add a general migration
framework until an actual contract change requires one. 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 ## Blue-Sky Platform And Operations
These ideas are intentionally less specified. Promote one into an earlier These ideas are intentionally less specified. Promote one into an earlier

View File

@@ -189,10 +189,18 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
} }
evidence := readProductionJSON[evidencecontext.Document](t, filepath.Join(runRoot, "evidence-context.json")) evidence := readProductionJSON[evidencecontext.Document](t, filepath.Join(runRoot, "evidence-context.json"))
for _, laneID := range []string{"enemy-events", "npc-registry", "npc-occurrences", "item-registry", "item-occurrences", "location-registry", "location-occurrences"} { if len(evidence) == 0 {
if !containsString(evidence.SelectedLanes, laneID) || !evidenceHasLane(evidence, laneID) { t.Fatalf("evidence context = %#v, want selected source-unit evidence", evidence)
t.Fatalf("evidence context = %#v, want direct %s evidence", evidence, laneID) }
seenEvidenceUnits := make(map[int]struct{}, len(evidence))
for _, unit := range evidence {
if unit.Ref.SourceID != "session-ravenfall" || unit.Ref.StartUnitID != unit.ID || unit.Ref.EndUnitID != unit.ID {
t.Fatalf("evidence unit = %#v, want unchanged source-unit self-reference", unit)
} }
if _, exists := seenEvidenceUnits[unit.ID]; exists {
t.Fatalf("evidence context = %#v, want each source unit once", evidence)
}
seenEvidenceUnits[unit.ID] = struct{}{}
} }
requests := client.requestsFor(enemyevents.PromptID) requests := client.requestsFor(enemyevents.PromptID)
@@ -415,17 +423,6 @@ func containsString(values []string, want string) bool {
return false return false
} }
func evidenceHasLane(value evidencecontext.Document, laneID string) bool {
for _, context := range value.Contexts {
for _, reference := range context.EvidenceRefs {
if reference.LaneID == laneID {
return true
}
}
}
return false
}
func generatedReferenceBinding(bindings []pipeline.ReferenceBinding, slotName string) (pipeline.ReferenceBinding, bool) { func generatedReferenceBinding(bindings []pipeline.ReferenceBinding, slotName string) (pipeline.ReferenceBinding, bool) {
for _, binding := range bindings { for _, binding := range bindings {
if binding.SlotName == slotName && binding.Artifact != nil { if binding.SlotName == slotName && binding.Artifact != nil {

View File

@@ -25,6 +25,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns" 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 { if _, err := pipeline.Prepare(effective.ResolvedPipeline, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil {
t.Fatalf("prepare production scene and spell modules: %v", err) 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) { func TestProductionSpellValidatorsPrepareFromMaterializedCatalog(t *testing.T) {

View File

@@ -2,24 +2,8 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.source.evidence_context", "$id": "notarius.source.evidence_context",
"title": "notarius_source_evidence_context_v1", "title": "notarius_source_evidence_context_v1",
"type": "object", "type": "array",
"additionalProperties": false, "items": {"$ref": "#/$defs/unit"},
"required": ["source_id", "source_digest", "window_units", "selected_lanes", "contexts"],
"properties": {
"source_id": {"type": "string", "minLength": 1},
"source_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
"window_units": {"type": "integer", "minimum": 0},
"selected_lanes": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {"type": "string", "minLength": 1}
},
"contexts": {
"type": "array",
"items": {"$ref": "#/$defs/context"}
}
},
"$defs": { "$defs": {
"source_ref": { "source_ref": {
"type": "object", "type": "object",
@@ -42,25 +26,6 @@
"ref": {"$ref": "#/$defs/source_ref"}, "ref": {"$ref": "#/$defs/source_ref"},
"metadata": {"type": "object", "additionalProperties": true} "metadata": {"type": "object", "additionalProperties": true}
} }
},
"evidence_ref": {
"type": "object",
"additionalProperties": false,
"required": ["lane_id", "source_ref"],
"properties": {
"lane_id": {"type": "string", "minLength": 1},
"source_ref": {"$ref": "#/$defs/source_ref"}
}
},
"context": {
"type": "object",
"additionalProperties": false,
"required": ["context_ref", "evidence_refs", "units"],
"properties": {
"context_ref": {"$ref": "#/$defs/source_ref"},
"evidence_refs": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/evidence_ref"}},
"units": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/unit"}}
}
} }
} }
} }

View File

@@ -3,118 +3,62 @@ package evidencecontext
import ( import (
"fmt" "fmt"
"sort" "sort"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
) )
type contribution struct { type expandedRange struct {
laneID string
ref source.SourceRef
startPos int startPos int
endPos int endPos int
} }
type expandedRange struct { // Build validates projected source references, expands them by source-document
startPos int // position, and returns their ordered union as an owned source-unit excerpt.
endPos int
contributions []contribution
}
// Build validates accepted direct references, expands them by source-document
// position, and returns their deterministic context union.
func Build(request BuildRequest) (Document, error) { func Build(request BuildRequest) (Document, error) {
if request.WindowUnits < 0 { if request.WindowUnits < 0 {
return Document{}, fmt.Errorf("window_units must not be negative") return nil, fmt.Errorf("window_units must not be negative")
}
lanes, err := normalizeSelectedLanes(request.SelectedLanes)
if err != nil {
return Document{}, err
} }
if err := source.ValidateDocument(request.Source); err != nil { if err := source.ValidateDocument(request.Source); err != nil {
return Document{}, fmt.Errorf("validate source document: %w", err) return nil, fmt.Errorf("validate source document: %w", err)
} }
digest, err := source.DigestDocument(request.Source) digest, err := source.DigestDocument(request.Source)
if err != nil { if err != nil {
return Document{}, fmt.Errorf("digest source document: %w", err) return nil, fmt.Errorf("digest source document: %w", err)
} }
if digest != request.Source.Digest { if digest != request.Source.Digest {
return Document{}, fmt.Errorf("source digest does not match source document digest") return nil, fmt.Errorf("source digest does not match source document digest")
} }
selected := make(map[string]struct{}, len(lanes))
for _, laneID := range lanes {
selected[laneID] = struct{}{}
}
index := source.NewDocumentIndex(request.Source) index := source.NewDocumentIndex(request.Source)
seen := make(map[evidenceKey]struct{}) ranges := make([]expandedRange, 0, len(request.SourceRefs))
contributions := make([]contribution, 0) for refIndex, ref := range request.SourceRefs {
for laneIndex, laneEvidence := range request.LaneEvidence { if err := index.ValidateRef(ref); err != nil {
laneID := strings.TrimSpace(laneEvidence.LaneID) return nil, fmt.Errorf("source reference[%d]: %w", refIndex, err)
if _, ok := selected[laneID]; !ok {
return Document{}, fmt.Errorf("lane evidence[%d] lane %q is not selected", laneIndex, laneID)
} }
for refIndex, ref := range laneEvidence.SourceRefs { startPos, _ := index.Position(ref.StartUnitID)
if err := index.ValidateRef(ref); err != nil { endPos, _ := index.Position(ref.EndUnitID)
return Document{}, fmt.Errorf("lane %q source reference[%d]: %w", laneID, refIndex, err) ranges = append(ranges, expandedRange{
startPos: expandStart(startPos, request.WindowUnits),
endPos: expandEnd(endPos, len(request.Source.Units), request.WindowUnits),
})
}
merged := mergeRanges(ranges)
unitCount := 0
for _, value := range merged {
unitCount += value.endPos - value.startPos + 1
}
document := make(Document, 0, unitCount)
for _, value := range merged {
for position := value.startPos; position <= value.endPos; position++ {
unit, err := cloneSourceUnit(request.Source.Units[position])
if err != nil {
return nil, fmt.Errorf("clone source unit at position %d: %w", position, err)
} }
key := evidenceKey{laneID: laneID, ref: ref} document = append(document, unit)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
startPos, _ := index.Position(ref.StartUnitID)
endPos, _ := index.Position(ref.EndUnitID)
contributions = append(contributions, contribution{laneID: laneID, ref: ref, startPos: expandStart(startPos, request.WindowUnits), endPos: expandEnd(endPos, len(request.Source.Units), request.WindowUnits)})
} }
} }
return document, nil
sort.Slice(contributions, func(i, j int) bool { return lessContribution(contributions[i], contributions[j]) })
document := Document{
SourceID: request.Source.ID,
SourceDigest: digest,
WindowUnits: request.WindowUnits,
SelectedLanes: lanes,
Contexts: make([]Context, 0),
}
for _, rangeValue := range mergeRanges(contributions) {
context, err := buildContext(request.Source, rangeValue)
if err != nil {
return Document{}, err
}
document.Contexts = append(document.Contexts, context)
}
canonical, err := canonicalizeOwned(document)
if err != nil {
return Document{}, fmt.Errorf("validate evidence context: %w", err)
}
return canonical, nil
}
type evidenceKey struct {
laneID string
ref source.SourceRef
}
func normalizeSelectedLanes(values []string) ([]string, error) {
if len(values) == 0 {
return nil, fmt.Errorf("selected_lanes must not be empty")
}
seen := make(map[string]struct{}, len(values))
lanes := make([]string, 0, len(values))
for index, raw := range values {
laneID := strings.TrimSpace(raw)
if laneID == "" {
return nil, fmt.Errorf("selected_lanes[%d] must not be empty", index)
}
if _, exists := seen[laneID]; exists {
return nil, fmt.Errorf("selected_lanes lane %q is duplicated", laneID)
}
seen[laneID] = struct{}{}
lanes = append(lanes, laneID)
}
sort.Strings(lanes)
return lanes, nil
} }
func expandStart(position, window int) int { func expandStart(position, window int) int {
@@ -132,65 +76,25 @@ func expandEnd(position, length, window int) int {
return position + window return position + window
} }
func lessContribution(left, right contribution) bool { func mergeRanges(values []expandedRange) []expandedRange {
if left.startPos != right.startPos {
return left.startPos < right.startPos
}
if left.endPos != right.endPos {
return left.endPos < right.endPos
}
return lessEvidenceRef(EvidenceRef{LaneID: left.laneID, SourceRef: left.ref}, EvidenceRef{LaneID: right.laneID, SourceRef: right.ref})
}
func mergeRanges(values []contribution) []expandedRange {
if len(values) == 0 { if len(values) == 0 {
return nil return nil
} }
ranges := make([]expandedRange, 0, len(values)) sort.Slice(values, func(i, j int) bool {
if values[i].startPos != values[j].startPos {
return values[i].startPos < values[j].startPos
}
return values[i].endPos < values[j].endPos
})
merged := make([]expandedRange, 0, len(values))
for _, value := range values { for _, value := range values {
if len(ranges) == 0 || value.startPos > ranges[len(ranges)-1].endPos+1 { if len(merged) == 0 || value.startPos > merged[len(merged)-1].endPos+1 {
ranges = append(ranges, expandedRange{startPos: value.startPos, endPos: value.endPos, contributions: []contribution{value}}) merged = append(merged, value)
continue continue
} }
current := &ranges[len(ranges)-1] if value.endPos > merged[len(merged)-1].endPos {
if value.endPos > current.endPos { merged[len(merged)-1].endPos = value.endPos
current.endPos = value.endPos
} }
current.contributions = append(current.contributions, value)
} }
return ranges return merged
}
func buildContext(document *source.SourceDocument, value expandedRange) (Context, error) {
evidenceRefs := make([]EvidenceRef, 0, len(value.contributions))
for _, contribution := range value.contributions {
evidenceRefs = append(evidenceRefs, EvidenceRef{LaneID: contribution.laneID, SourceRef: contribution.ref})
}
sort.Slice(evidenceRefs, func(i, j int) bool { return lessEvidenceRef(evidenceRefs[i], evidenceRefs[j]) })
units := make([]source.SourceUnit, 0, value.endPos-value.startPos+1)
for position := value.startPos; position <= value.endPos; position++ {
unit, err := cloneSourceUnit(document.Units[position])
if err != nil {
return Context{}, fmt.Errorf("clone source unit at position %d: %w", position, err)
}
units = append(units, unit)
}
return Context{
ContextRef: source.SourceRef{SourceID: document.ID, StartUnitID: units[0].ID, EndUnitID: units[len(units)-1].ID},
EvidenceRefs: evidenceRefs,
Units: units,
}, nil
}
func lessEvidenceRef(left, right EvidenceRef) bool {
if left.LaneID != right.LaneID {
return left.LaneID < right.LaneID
}
if left.SourceRef.SourceID != right.SourceRef.SourceID {
return left.SourceRef.SourceID < right.SourceRef.SourceID
}
if left.SourceRef.StartUnitID != right.SourceRef.StartUnitID {
return left.SourceRef.StartUnitID < right.SourceRef.StartUnitID
}
return left.SourceRef.EndUnitID < right.SourceRef.EndUnitID
} }

View File

@@ -6,7 +6,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"regexp"
"strings" "strings"
"sync" "sync"
@@ -18,8 +17,6 @@ import (
//go:embed assets/schemas/source_evidence_context.v1.json //go:embed assets/schemas/source_evidence_context.v1.json
var schemaAssets embed.FS var schemaAssets embed.FS
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
var ( var (
loadSchemaOnce sync.Once loadSchemaOnce sync.Once
loadedSchema []byte loadedSchema []byte
@@ -78,24 +75,24 @@ func (c *Codec) Encode(value Document) ([]byte, error) {
func (c *Codec) Decode(content []byte) (Document, error) { func (c *Codec) Decode(content []byte) (Document, error) {
if _, err := c.schemaBytes(); err != nil { if _, err := c.schemaBytes(); err != nil {
return Document{}, err return nil, err
} }
if err := validateSchemaInstance(content); err != nil { if err := validateSchemaInstance(content); err != nil {
return Document{}, fmt.Errorf("decode evidence context: %w", err) return nil, fmt.Errorf("decode evidence context: %w", err)
} }
decoder := json.NewDecoder(bytes.NewReader(content)) decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields() decoder.DisallowUnknownFields()
var value Document var value Document
if err := decoder.Decode(&value); err != nil { if err := decoder.Decode(&value); err != nil {
return Document{}, fmt.Errorf("decode evidence context: %w", err) return nil, fmt.Errorf("decode evidence context: %w", err)
} }
var trailing any var trailing any
if err := decoder.Decode(&trailing); err != io.EOF { if err := decoder.Decode(&trailing); err != io.EOF {
return Document{}, fmt.Errorf("decode evidence context: multiple JSON values") return nil, fmt.Errorf("decode evidence context: multiple JSON values")
} }
canonical, err := canonicalizeOwned(value) canonical, err := canonicalizeOwned(value)
if err != nil { if err != nil {
return Document{}, fmt.Errorf("decode evidence context: %w", err) return nil, fmt.Errorf("decode evidence context: %w", err)
} }
return canonical, nil return canonical, nil
} }
@@ -115,17 +112,16 @@ func loadAndCompileSchema() {
return return
} }
var identity struct { var identity struct {
ID string `json:"$id"` ID string `json:"$id"`
Title string `json:"title"` Title string `json:"title"`
Type string `json:"type"` Type string `json:"type"`
Required []string `json:"required"`
} }
if err := json.Unmarshal(raw, &identity); err != nil { if err := json.Unmarshal(raw, &identity); err != nil {
loadSchemaErr = fmt.Errorf("decode source evidence context schema: %w", err) loadSchemaErr = fmt.Errorf("decode source evidence context schema: %w", err)
return return
} }
if identity.ID != SchemaID || identity.Title != SchemaName || identity.Type != "object" || !hasRequiredFields(identity.Required) { if identity.ID != SchemaID || identity.Title != SchemaName || identity.Type != "array" {
loadSchemaErr = fmt.Errorf("source evidence context schema identity or required fields are invalid") loadSchemaErr = fmt.Errorf("source evidence context schema identity is invalid")
return return
} }
schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
@@ -158,164 +154,65 @@ func validateSchemaInstance(content []byte) error {
return nil return nil
} }
func hasRequiredFields(required []string) bool {
want := map[string]bool{"source_id": true, "source_digest": true, "window_units": true, "selected_lanes": true, "contexts": true}
for _, field := range required {
delete(want, field)
}
return len(want) == 0
}
func canonicalize(value Document) (Document, error) { func canonicalize(value Document) (Document, error) {
owned, err := clone(value) owned, err := clone(value)
if err != nil { if err != nil {
return Document{}, err return nil, err
} }
return canonicalizeOwned(owned) return canonicalizeOwned(owned)
} }
func canonicalizeOwned(value Document) (Document, error) { func canonicalizeOwned(value Document) (Document, error) {
if err := requireIdentity("source_id", value.SourceID); err != nil { if value == nil {
return Document{}, err return nil, fmt.Errorf("document must be a JSON array")
} }
if !digestPattern.MatchString(value.SourceDigest) { seenUnitIDs := make(map[int]struct{}, len(value))
return Document{}, fmt.Errorf("source_digest must be a sha256 digest") sourceID := ""
} for unitIndex := range value {
if value.WindowUnits < 0 { unit := value[unitIndex]
return Document{}, fmt.Errorf("window_units must not be negative")
}
if err := validateSelectedLanes(value.SelectedLanes); err != nil {
return Document{}, err
}
if value.Contexts == nil {
value.Contexts = make([]Context, 0)
}
selected := make(map[string]struct{}, len(value.SelectedLanes))
for _, laneID := range value.SelectedLanes {
selected[laneID] = struct{}{}
}
seenUnits := make(map[int]struct{})
for contextIndex := range value.Contexts {
context, err := canonicalizeContext(value.SourceID, selected, seenUnits, value.Contexts[contextIndex], contextIndex)
if err != nil {
return Document{}, err
}
value.Contexts[contextIndex] = context
}
return value, nil
}
func validateSelectedLanes(lanes []string) error {
if len(lanes) == 0 {
return fmt.Errorf("selected_lanes must not be empty")
}
for index, laneID := range lanes {
if err := requireIdentity(fmt.Sprintf("selected_lanes[%d]", index), laneID); err != nil {
return err
}
if index > 0 && lanes[index-1] >= laneID {
return fmt.Errorf("selected_lanes must be unique and in lexical order")
}
}
return nil
}
func canonicalizeContext(sourceID string, selected map[string]struct{}, seenUnits map[int]struct{}, value Context, contextIndex int) (Context, error) {
prefix := fmt.Sprintf("contexts[%d]", contextIndex)
if len(value.EvidenceRefs) == 0 {
return Context{}, fmt.Errorf("%s.evidence_refs must not be empty", prefix)
}
if len(value.Units) == 0 {
return Context{}, fmt.Errorf("%s.units must not be empty", prefix)
}
if err := validateRefIdentity(sourceID, value.ContextRef, prefix+".context_ref"); err != nil {
return Context{}, err
}
positions := make(map[int]int, len(value.Units))
for unitIndex := range value.Units {
unit := value.Units[unitIndex]
if unit.ID <= 0 || strings.TrimSpace(unit.Kind) == "" || strings.TrimSpace(unit.Text) == "" { if unit.ID <= 0 || strings.TrimSpace(unit.Kind) == "" || strings.TrimSpace(unit.Text) == "" {
return Context{}, fmt.Errorf("%s.units[%d] has invalid required fields", prefix, unitIndex) return nil, fmt.Errorf("units[%d] has invalid required fields", unitIndex)
} }
if err := validateRefIdentity(sourceID, unit.Ref, fmt.Sprintf("%s.units[%d].ref", prefix, unitIndex)); err != nil { if err := validateUnitRef(unit, unitIndex); err != nil {
return Context{}, err return nil, err
} }
if unit.Ref.StartUnitID != unit.ID || unit.Ref.EndUnitID != unit.ID { if sourceID == "" {
return Context{}, fmt.Errorf("%s.units[%d].ref must identify unit id %d", prefix, unitIndex, unit.ID) sourceID = unit.Ref.SourceID
} else if unit.Ref.SourceID != sourceID {
return nil, fmt.Errorf("units[%d].ref.source_id must match units[0].ref.source_id", unitIndex)
} }
if _, exists := positions[unit.ID]; exists { if _, exists := seenUnitIDs[unit.ID]; exists {
return Context{}, fmt.Errorf("%s.units contains duplicate unit id %d", prefix, unit.ID) return nil, fmt.Errorf("units contains duplicate unit id %d", unit.ID)
}
if _, exists := seenUnits[unit.ID]; exists {
return Context{}, fmt.Errorf("contexts contain duplicate unit id %d", unit.ID)
}
positions[unit.ID] = unitIndex
seenUnits[unit.ID] = struct{}{}
}
if value.ContextRef.StartUnitID != value.Units[0].ID || value.ContextRef.EndUnitID != value.Units[len(value.Units)-1].ID {
return Context{}, fmt.Errorf("%s.context_ref must identify the first and last units", prefix)
}
for evidenceIndex := range value.EvidenceRefs {
evidence := value.EvidenceRefs[evidenceIndex]
if _, ok := selected[evidence.LaneID]; !ok {
return Context{}, fmt.Errorf("%s.evidence_refs[%d].lane_id is not selected", prefix, evidenceIndex)
}
if err := requireIdentity(fmt.Sprintf("%s.evidence_refs[%d].lane_id", prefix, evidenceIndex), evidence.LaneID); err != nil {
return Context{}, err
}
if err := validateRefIdentity(sourceID, evidence.SourceRef, fmt.Sprintf("%s.evidence_refs[%d].source_ref", prefix, evidenceIndex)); err != nil {
return Context{}, err
}
start, startOK := positions[evidence.SourceRef.StartUnitID]
end, endOK := positions[evidence.SourceRef.EndUnitID]
if !startOK || !endOK || start > end {
return Context{}, fmt.Errorf("%s.evidence_refs[%d].source_ref is outside context units", prefix, evidenceIndex)
}
if evidenceIndex > 0 && !lessEvidenceRef(value.EvidenceRefs[evidenceIndex-1], evidence) {
return Context{}, fmt.Errorf("%s.evidence_refs must be unique and in deterministic order", prefix)
} }
seenUnitIDs[unit.ID] = struct{}{}
} }
return value, nil return value, nil
} }
func validateRefIdentity(sourceID string, ref source.SourceRef, field string) error { func validateUnitRef(unit source.SourceUnit, unitIndex int) error {
if ref.SourceID != sourceID { prefix := fmt.Sprintf("units[%d].ref", unitIndex)
return fmt.Errorf("%s.source_id does not match source_id", field) if strings.TrimSpace(unit.Ref.SourceID) == "" || strings.TrimSpace(unit.Ref.SourceID) != unit.Ref.SourceID {
return fmt.Errorf("%s.source_id must be a non-empty trimmed string", prefix)
} }
if ref.StartUnitID <= 0 || ref.EndUnitID <= 0 { if unit.Ref.StartUnitID != unit.ID || unit.Ref.EndUnitID != unit.ID {
return fmt.Errorf("%s endpoints must be positive", field) return fmt.Errorf("%s must identify unit id %d", prefix, unit.ID)
}
return nil
}
func requireIdentity(field, value string) error {
if strings.TrimSpace(value) == "" || strings.TrimSpace(value) != value {
return fmt.Errorf("%s must be a non-empty trimmed string", field)
} }
return nil return nil
} }
func clone(value Document) (Document, error) { func clone(value Document) (Document, error) {
value.SelectedLanes = append([]string(nil), value.SelectedLanes...) if value == nil {
if value.Contexts == nil { return nil, nil
value.Contexts = make([]Context, 0)
} else {
contexts := make([]Context, len(value.Contexts))
for contextIndex, context := range value.Contexts {
contexts[contextIndex].ContextRef = context.ContextRef
contexts[contextIndex].EvidenceRefs = append([]EvidenceRef(nil), context.EvidenceRefs...)
contexts[contextIndex].Units = make([]source.SourceUnit, len(context.Units))
for unitIndex, unit := range context.Units {
cloned, err := cloneSourceUnit(unit)
if err != nil {
return Document{}, fmt.Errorf("clone contexts[%d].units[%d]: %w", contextIndex, unitIndex, err)
}
contexts[contextIndex].Units[unitIndex] = cloned
}
}
value.Contexts = contexts
} }
return value, nil cloned := make(Document, len(value))
for unitIndex, unit := range value {
owned, err := cloneSourceUnit(unit)
if err != nil {
return nil, fmt.Errorf("clone units[%d]: %w", unitIndex, err)
}
cloned[unitIndex] = owned
}
return cloned, nil
} }
func cloneSourceUnit(unit source.SourceUnit) (source.SourceUnit, error) { func cloneSourceUnit(unit source.SourceUnit) (source.SourceUnit, error) {

View File

@@ -2,7 +2,6 @@ package evidencecontext
import ( import (
"bytes" "bytes"
"encoding/json"
"math" "math"
"os" "os"
"reflect" "reflect"
@@ -12,126 +11,57 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
) )
func TestBuildExpandsAndMergesEvidenceByDocumentPosition(t *testing.T) { func TestBuildSelectsExpandedSourceUnitUnion(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name string name string
window int window int
evidence []LaneEvidence refs []source.SourceRef
wantUnits [][]int wantIDs []int
wantRefs [][]EvidenceRef
}{ }{
{ {name: "zero window", refs: []source.SourceRef{ref(3, 3)}, wantIDs: []int{3}},
name: "zero window", {name: "multi unit citation includes complete range", refs: []source.SourceRef{ref(3, 7)}, wantIDs: []int{3, 30, 7}},
evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}}, {name: "non monotonic IDs use document positions", window: 1, refs: []source.SourceRef{ref(3, 3)}, wantIDs: []int{10, 3, 30}},
wantUnits: [][]int{{3}}, {name: "boundary clamping", window: 1, refs: []source.SourceRef{ref(10, 10), ref(50, 50)}, wantIDs: []int{10, 3, 7, 50}},
wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(3, 3)}}}, {name: "overlapping and adjacent windows merge", window: 1, refs: []source.SourceRef{ref(3, 3), ref(30, 30), ref(30, 30)}, wantIDs: []int{10, 3, 30, 7}},
}, {name: "adjacent expanded ranges merge", window: 1, refs: []source.SourceRef{ref(10, 10), ref(7, 7)}, wantIDs: []int{10, 3, 30, 7, 50}},
{ {name: "largest window clips without overflow", window: math.MaxInt, refs: []source.SourceRef{ref(30, 30)}, wantIDs: []int{10, 3, 30, 7, 50}},
name: "non monotonic ids use positions and clip boundaries", {name: "no references returns an initialized empty document", wantIDs: []int{}},
window: 1,
evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}},
wantUnits: [][]int{{10, 3, 30}},
wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(3, 3)}}},
},
{
name: "separate gaps stay separate",
evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(10, 10), ref(50, 50)}}},
wantUnits: [][]int{{10}, {50}},
wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(10, 10)}}, {{LaneID: "npcs", SourceRef: ref(50, 50)}}},
},
{
name: "overlapping windows merge",
window: 1,
evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3), ref(30, 30)}}},
wantUnits: [][]int{{10, 3, 30, 7}},
wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(3, 3)}, {LaneID: "npcs", SourceRef: ref(30, 30)}}},
},
{
name: "contiguous windows merge",
window: 1,
evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(10, 10), ref(7, 7)}}},
wantUnits: [][]int{{10, 3, 30, 7, 50}},
wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(7, 7)}, {LaneID: "npcs", SourceRef: ref(10, 10)}}},
},
{
name: "duplicate contributions retain unique lane attribution",
evidence: []LaneEvidence{
{LaneID: "spells", SourceRefs: []source.SourceRef{ref(30, 30), ref(30, 30)}},
{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(30, 30)}},
},
wantUnits: [][]int{{30}},
wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(30, 30)}, {LaneID: "spells", SourceRef: ref(30, 30)}}},
},
{
name: "empty contributions retain explicit empty contexts",
evidence: []LaneEvidence{{LaneID: "npcs"}},
wantUnits: [][]int{},
wantRefs: [][]EvidenceRef{},
},
{
name: "largest window clips without overflow",
window: math.MaxInt,
evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(30, 30)}}},
wantUnits: [][]int{{10, 3, 30, 7, 50}},
wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(30, 30)}}},
},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
document := testDocument(t) got, err := Build(BuildRequest{Source: testDocument(t), WindowUnits: test.window, SourceRefs: test.refs})
got, err := Build(BuildRequest{Source: document, WindowUnits: test.window, SelectedLanes: []string{"spells", "npcs"}, LaneEvidence: test.evidence})
if err != nil { if err != nil {
t.Fatalf("Build() error = %v", err) t.Fatalf("Build() error = %v", err)
} }
if want := []string{"npcs", "spells"}; !reflect.DeepEqual(got.SelectedLanes, want) { if got == nil {
t.Fatalf("SelectedLanes = %#v, want %#v", got.SelectedLanes, want) t.Fatal("Build() returned a nil document")
} }
if got.WindowUnits != test.window || got.SourceID != document.ID || got.SourceDigest != document.Digest { if actual := unitIDs(got); !reflect.DeepEqual(actual, test.wantIDs) {
t.Fatalf("Build() identity = %#v, want source and window identity", got) t.Fatalf("unit IDs = %#v, want %#v", actual, test.wantIDs)
}
if actual := contextUnitIDs(got.Contexts); !reflect.DeepEqual(actual, test.wantUnits) {
t.Fatalf("context unit ids = %#v, want %#v", actual, test.wantUnits)
}
if actual := contextEvidenceRefs(got.Contexts); !reflect.DeepEqual(actual, test.wantRefs) {
t.Fatalf("context evidence refs = %#v, want %#v", actual, test.wantRefs)
} }
}) })
} }
} }
func TestBuildIsStableAndOwnsSourceAndInputs(t *testing.T) { func TestBuildCopiesSelectedUnitsAndMetadata(t *testing.T) {
document := testDocument(t) document := testDocument(t)
refs := []source.SourceRef{ref(30, 30), ref(3, 3)} first, err := Build(BuildRequest{Source: document, WindowUnits: 1, SourceRefs: []source.SourceRef{ref(3, 3)}})
request := BuildRequest{
Source: document,
WindowUnits: 1,
SelectedLanes: []string{"spells", "npcs"},
LaneEvidence: []LaneEvidence{{LaneID: "spells", SourceRefs: refs}, {LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}},
}
first, err := Build(request)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
secondRequest := request second, err := Build(BuildRequest{Source: document, WindowUnits: 1, SourceRefs: []source.SourceRef{ref(3, 3)}})
secondRequest.LaneEvidence = []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}, {LaneID: "spells", SourceRefs: []source.SourceRef{ref(3, 3), ref(30, 30)}}}
second, err := Build(secondRequest)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !reflect.DeepEqual(first, second) { if !reflect.DeepEqual(first[0], document.Units[0]) {
t.Fatalf("Build() order differs:\nfirst: %#v\nsecond: %#v", first, second) t.Fatalf("first unit = %#v, want unchanged source unit %#v", first[0], document.Units[0])
} }
first.SelectedLanes[0] = "changed" first[0].Metadata["nested"].(map[string]any)["value"] = "changed"
first.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] = "changed"
if document.Units[0].Metadata["nested"].(map[string]any)["value"] != "original" { if document.Units[0].Metadata["nested"].(map[string]any)["value"] != "original" {
t.Fatal("Build() returned metadata aliases to source document") t.Fatal("Build() returned metadata aliases to the source document")
} }
document.Units[0].Metadata["nested"].(map[string]any)["value"] = "later" document.Units[0].Metadata["nested"].(map[string]any)["value"] = "later"
if second.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] != "original" { if second[0].Metadata["nested"].(map[string]any)["value"] != "original" {
t.Fatal("Build() retained metadata aliases to source document") t.Fatal("Build() retained metadata aliases to the source document")
}
refs[0].StartUnitID = 999
if !containsEvidenceRef(second.Contexts[0].EvidenceRefs, ref(30, 30)) {
t.Fatal("Build() retained source-reference input aliases")
} }
} }
@@ -142,18 +72,11 @@ func TestBuildRejectsInvalidInputs(t *testing.T) {
want string want string
}{ }{
{name: "negative window", mutate: func(request *BuildRequest) { request.WindowUnits = -1 }, want: "window_units"}, {name: "negative window", mutate: func(request *BuildRequest) { request.WindowUnits = -1 }, want: "window_units"},
{name: "blank selected lane", mutate: func(request *BuildRequest) { request.SelectedLanes = []string{" "} }, want: "selected_lanes"},
{name: "duplicate selected lane", mutate: func(request *BuildRequest) { request.SelectedLanes = []string{"npcs", " npcs "} }, want: "duplicated"},
{name: "unselected contribution", mutate: func(request *BuildRequest) {
request.LaneEvidence = []LaneEvidence{{LaneID: "other", SourceRefs: []source.SourceRef{ref(3, 3)}}}
}, want: "not selected"},
{name: "source digest mismatch", mutate: func(request *BuildRequest) { request.Source.Digest = "sha256:" + strings.Repeat("0", 64) }, want: "does not match"}, {name: "source digest mismatch", mutate: func(request *BuildRequest) { request.Source.Digest = "sha256:" + strings.Repeat("0", 64) }, want: "does not match"},
{name: "invalid reference", mutate: func(request *BuildRequest) { {name: "invalid reference", mutate: func(request *BuildRequest) { request.SourceRefs = []source.SourceRef{ref(99, 99)} }, want: "source reference[0]"},
request.LaneEvidence = []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(99, 99)}}}
}, want: "source reference[0]"},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
request := BuildRequest{Source: testDocument(t), SelectedLanes: []string{"npcs"}, LaneEvidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}}} request := BuildRequest{Source: testDocument(t), SourceRefs: []source.SourceRef{ref(3, 3)}}
test.mutate(&request) test.mutate(&request)
if _, err := Build(request); err == nil || !strings.Contains(err.Error(), test.want) { if _, err := Build(request); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Build() error = %v, want %q", err, test.want) t.Fatalf("Build() error = %v, want %q", err, test.want)
@@ -162,7 +85,7 @@ func TestBuildRejectsInvalidInputs(t *testing.T) {
} }
} }
func TestCodecRoundTripsCompactFixtureAndOwnsDecodedValues(t *testing.T) { func TestCodecRoundTripsFixtureAndOwnsValues(t *testing.T) {
fixture, err := os.ReadFile("testdata/source_evidence_context.v1.json") fixture, err := os.ReadFile("testdata/source_evidence_context.v1.json")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -179,15 +102,16 @@ func TestCodecRoundTripsCompactFixtureAndOwnsDecodedValues(t *testing.T) {
if !bytes.Equal(encoded, bytes.TrimSpace(fixture)) { if !bytes.Equal(encoded, bytes.TrimSpace(fixture)) {
t.Fatalf("fixture does not use canonical encoding\nwant: %s\n got: %s", fixture, encoded) t.Fatalf("fixture does not use canonical encoding\nwant: %s\n got: %s", fixture, encoded)
} }
value.Contexts[0].Units[0].Text = "changed" value[0].Text = "changed"
decoded, err := codec.Decode(encoded) decoded, err := codec.Decode(encoded)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if decoded.Contexts[0].Units[0].Text != "The party meets Rowan." { if decoded[0].Text != "The party meets Rowan." {
t.Fatal("Encode() retained mutable document storage") t.Fatal("Encode() retained mutable document storage")
} }
built, err := Build(BuildRequest{Source: testDocument(t), WindowUnits: 1, SelectedLanes: []string{"npcs"}, LaneEvidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}}})
built, err := Build(BuildRequest{Source: testDocument(t), WindowUnits: 1, SourceRefs: []source.SourceRef{ref(3, 3)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -203,82 +127,52 @@ func TestCodecRoundTripsCompactFixtureAndOwnsDecodedValues(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
first.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] = "changed" first[0].Metadata["nested"].(map[string]any)["value"] = "changed"
if second.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] != "original" { if second[0].Metadata["nested"].(map[string]any)["value"] != "original" {
t.Fatal("Decode() returned metadata aliases") t.Fatal("Decode() returned metadata aliases")
} }
} }
func TestCodecRejectsInvalidDurableBoundaries(t *testing.T) { func TestCodecRejectsInvalidDurablePayloads(t *testing.T) {
value, err := Build(BuildRequest{Source: testDocument(t), SelectedLanes: []string{"npcs"}, LaneEvidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}}})
if err != nil {
t.Fatal(err)
}
for _, test := range []struct { for _, test := range []struct {
name string name string
mutate func(*Document) content string
}{ }{
{name: "unsorted lanes", mutate: func(value *Document) { value.SelectedLanes = []string{"z", "a"} }}, {name: "null", content: "null"},
{name: "context range mismatch", mutate: func(value *Document) { value.Contexts[0].ContextRef.EndUnitID = 999 }}, {name: "wrapper object", content: `{"units":[]}`},
{name: "mismatched evidence source", mutate: func(value *Document) { value.Contexts[0].EvidenceRefs[0].SourceRef.SourceID = "other" }}, {name: "missing required unit field", content: `[{"id":1,"kind":"segment","ref":{"source_id":"session","start_unit_id":1,"end_unit_id":1}}]`},
{name: "invalid evidence range", mutate: func(value *Document) { {name: "unknown unit field", content: `[{"id":1,"kind":"segment","text":"text","ref":{"source_id":"session","start_unit_id":1,"end_unit_id":1},"unknown":true}]`},
value.Contexts[0].EvidenceRefs[0].SourceRef.StartUnitID = 10 {name: "unknown reference field", content: `[{"id":1,"kind":"segment","text":"text","ref":{"source_id":"session","start_unit_id":1,"end_unit_id":1,"unknown":true}}]`},
}}, {name: "invalid self reference", content: `[{"id":1,"kind":"segment","text":"text","ref":{"source_id":"session","start_unit_id":1,"end_unit_id":2}}]`},
{name: "duplicate context unit", mutate: func(value *Document) { value.Contexts = append(value.Contexts, value.Contexts[0]) }}, {name: "mixed source documents", content: `[{"id":1,"kind":"segment","text":"one","ref":{"source_id":"session-one","start_unit_id":1,"end_unit_id":1}},{"id":2,"kind":"segment","text":"two","ref":{"source_id":"session-two","start_unit_id":2,"end_unit_id":2}}]`},
{name: "duplicate units", content: `[{"id":1,"kind":"segment","text":"one","ref":{"source_id":"session","start_unit_id":1,"end_unit_id":1}},{"id":1,"kind":"segment","text":"two","ref":{"source_id":"session","start_unit_id":1,"end_unit_id":1}}]`},
{name: "multiple JSON values", content: `[] []`},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
candidate, err := clone(value) if _, err := New().Decode([]byte(test.content)); err == nil {
if err != nil {
t.Fatal(err)
}
test.mutate(&candidate)
if _, err := New().Encode(candidate); err == nil {
t.Fatal("Encode() error = nil, want durable model rejection")
}
})
}
content, err := New().Encode(value)
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
mutate func(map[string]any)
}{
{name: "missing contexts", mutate: func(value map[string]any) { delete(value, "contexts") }},
{name: "null contexts", mutate: func(value map[string]any) { value["contexts"] = nil }},
{name: "unknown fixed field", mutate: func(value map[string]any) { value["unknown"] = true }},
{name: "missing units", mutate: func(value map[string]any) { delete(contextObject(value, 0), "units") }},
{name: "null evidence refs", mutate: func(value map[string]any) { contextObject(value, 0)["evidence_refs"] = nil }},
} {
t.Run(test.name, func(t *testing.T) {
raw := decodeJSON(t, content)
test.mutate(raw)
mutated, err := json.Marshal(raw)
if err != nil {
t.Fatal(err)
}
if _, err := New().Decode(mutated); err == nil {
t.Fatal("Decode() error = nil, want strict payload rejection") t.Fatal("Decode() error = nil, want strict payload rejection")
} }
}) })
} }
if _, err := New().Decode(append(content, []byte(" {}")...)); err == nil { if _, err := New().Encode(nil); err == nil {
t.Fatal("Decode() error = nil, want trailing JSON rejection") t.Fatal("Encode(nil) error = nil, want array rejection")
} }
} }
func TestSerializeUsesFixedArtifactIdentity(t *testing.T) { func TestSerializeUsesFixedArtifactIdentityAndEmptyArray(t *testing.T) {
artifact, err := Serialize(BuildRequest{Source: testDocument(t), SelectedLanes: []string{"npcs"}}) artifact, err := Serialize(BuildRequest{Source: testDocument(t)})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if artifact.Kind != ArtifactKind || artifact.MediaType != MediaType || artifact.Schema.ID != SchemaID || artifact.Schema.Name != SchemaName || artifact.Schema.Version != SchemaVersion { if artifact.Kind != ArtifactKind || artifact.MediaType != MediaType || artifact.Schema.ID != SchemaID || artifact.Schema.Name != SchemaName || artifact.Schema.Version != SchemaVersion {
t.Fatalf("Serialize() = %#v, want fixed artifact identity", artifact) t.Fatalf("Serialize() = %#v, want fixed artifact identity", artifact)
} }
if string(artifact.Content) != "[]" {
t.Fatalf("Serialize() content = %s, want []", artifact.Content)
}
decoded, err := New().Decode(artifact.Content) decoded, err := New().Decode(artifact.Content)
if err != nil || len(decoded.Contexts) != 0 || decoded.Contexts == nil { if err != nil || decoded == nil || len(decoded) != 0 {
t.Fatalf("Decode(Serialize()) = %#v, %v; want explicit empty contexts", decoded, err) t.Fatalf("Decode(Serialize()) = %#v, %v; want explicit empty array", decoded, err)
} }
} }
@@ -306,45 +200,10 @@ func ref(start, end int) source.SourceRef {
return source.SourceRef{SourceID: "session", StartUnitID: start, EndUnitID: end} return source.SourceRef{SourceID: "session", StartUnitID: start, EndUnitID: end}
} }
func contextUnitIDs(contexts []Context) [][]int { func unitIDs(units Document) []int {
values := make([][]int, len(contexts)) values := make([]int, len(units))
for index, context := range contexts { for index, unit := range units {
values[index] = make([]int, len(context.Units)) values[index] = unit.ID
for unitIndex, unit := range context.Units {
values[index][unitIndex] = unit.ID
}
} }
return values return values
} }
func contextEvidenceRefs(contexts []Context) [][]EvidenceRef {
values := make([][]EvidenceRef, len(contexts))
for index, context := range contexts {
values[index] = append([]EvidenceRef(nil), context.EvidenceRefs...)
}
return values
}
func containsEvidenceRef(values []EvidenceRef, want source.SourceRef) bool {
for _, value := range values {
if value.SourceRef == want {
return true
}
}
return false
}
func decodeJSON(t *testing.T, content []byte) map[string]any {
t.Helper()
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.UseNumber()
var value map[string]any
if err := decoder.Decode(&value); err != nil {
t.Fatal(err)
}
return value
}
func contextObject(value map[string]any, index int) map[string]any {
return value["contexts"].([]any)[index].(map[string]any)
}

View File

@@ -14,37 +14,12 @@ const (
MediaType = "application/json" MediaType = "application/json"
) )
// Document is the durable union of direct evidence and surrounding source // Document is the durable selected source-unit excerpt.
// context selected for one accepted source document. type Document []source.SourceUnit
type Document struct {
SourceID string `json:"source_id"`
SourceDigest string `json:"source_digest"`
WindowUnits int `json:"window_units"`
SelectedLanes []string `json:"selected_lanes"`
Contexts []Context `json:"contexts"`
}
type Context struct { // BuildRequest supplies accepted source material and projected source references.
ContextRef source.SourceRef `json:"context_ref"`
EvidenceRefs []EvidenceRef `json:"evidence_refs"`
Units []source.SourceUnit `json:"units"`
}
type EvidenceRef struct {
LaneID string `json:"lane_id"`
SourceRef source.SourceRef `json:"source_ref"`
}
// LaneEvidence attributes direct source references to one selected lane.
type LaneEvidence struct {
LaneID string `json:"lane_id"`
SourceRefs []source.SourceRef `json:"source_refs"`
}
// BuildRequest supplies accepted source material and direct lane evidence.
type BuildRequest struct { type BuildRequest struct {
Source *source.SourceDocument Source *source.SourceDocument
WindowUnits int WindowUnits int
SelectedLanes []string SourceRefs []source.SourceRef
LaneEvidence []LaneEvidence
} }

View File

@@ -1 +1 @@
{"source_id":"session-alpha","source_digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","window_units":0,"selected_lanes":["npcs"],"contexts":[{"context_ref":{"source_id":"session-alpha","start_unit_id":13,"end_unit_id":13},"evidence_refs":[{"lane_id":"npcs","source_ref":{"source_id":"session-alpha","start_unit_id":13,"end_unit_id":13}}],"units":[{"id":13,"kind":"transcript_segment","text":"The party meets Rowan.","ref":{"source_id":"session-alpha","start_unit_id":13,"end_unit_id":13}}]}]} [{"id":13,"kind":"transcript_segment","text":"The party meets Rowan.","ref":{"source_id":"session-alpha","start_unit_id":13,"end_unit_id":13}}]

View File

@@ -20,7 +20,6 @@ type debugEvidenceContextSummary struct {
SchemaVersion string `json:"schema_version"` SchemaVersion string `json:"schema_version"`
SelectedLanes []string `json:"selected_lanes"` SelectedLanes []string `json:"selected_lanes"`
WindowUnits int `json:"window_units"` WindowUnits int `json:"window_units"`
ContextCount int `json:"context_count"`
UnitCount int `json:"unit_count"` UnitCount int `json:"unit_count"`
SourceDigest string `json:"source_digest"` SourceDigest string `json:"source_digest"`
} }
@@ -49,10 +48,9 @@ func buildOutputEvidenceContext(prepared *PreparedPipeline, doc *source.SourceDo
} }
request := evidencecontext.BuildRequest{ request := evidencecontext.BuildRequest{
Source: doc, Source: doc,
WindowUnits: prepared.evidencePlan.policy.WindowUnits, WindowUnits: prepared.evidencePlan.policy.WindowUnits,
SelectedLanes: append([]string(nil), prepared.evidencePlan.policy.LaneIDs...), SourceRefs: make([]source.SourceRef, 0),
LaneEvidence: make([]evidencecontext.LaneEvidence, 0, len(prepared.evidencePlan.lanes)),
} }
for _, lane := range prepared.evidencePlan.lanes { for _, lane := range prepared.evidencePlan.lanes {
output, ok := byLane[lane.laneID] output, ok := byLane[lane.laneID]
@@ -73,10 +71,7 @@ func buildOutputEvidenceContext(prepared *PreparedPipeline, doc *source.SourceDo
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("evidence context output lane %q: accepted normalized artifact cannot be projected", lane.laneID) return nil, nil, fmt.Errorf("evidence context output lane %q: accepted normalized artifact cannot be projected", lane.laneID)
} }
request.LaneEvidence = append(request.LaneEvidence, evidencecontext.LaneEvidence{ request.SourceRefs = append(request.SourceRefs, references...)
LaneID: lane.laneID,
SourceRefs: append([]source.SourceRef(nil), references...),
})
} }
document, err := evidencecontext.Build(request) document, err := evidencecontext.Build(request)
@@ -99,13 +94,10 @@ func buildOutputEvidenceContext(prepared *PreparedPipeline, doc *source.SourceDo
SchemaID: artifact.Schema.ID, SchemaID: artifact.Schema.ID,
SchemaName: artifact.Schema.Name, SchemaName: artifact.Schema.Name,
SchemaVersion: artifact.Schema.Version, SchemaVersion: artifact.Schema.Version,
SelectedLanes: append([]string(nil), document.SelectedLanes...), SelectedLanes: append([]string(nil), prepared.evidencePlan.policy.LaneIDs...),
WindowUnits: document.WindowUnits, WindowUnits: prepared.evidencePlan.policy.WindowUnits,
ContextCount: len(document.Contexts), SourceDigest: doc.Digest,
SourceDigest: document.SourceDigest, UnitCount: len(document),
}
for _, context := range document.Contexts {
summary.UnitCount += len(context.Units)
} }
return contracts.CloneSerializedArtifactPointer(artifact), &summary, nil return contracts.CloneSerializedArtifactPointer(artifact), &summary, nil
} }

View File

@@ -97,14 +97,11 @@ func TestRunnerBuildsEvidenceContextFromSelectedNormalizedOutputs(t *testing.T)
} }
value := decodeCapturedEvidence(t, encoder) value := decodeCapturedEvidence(t, encoder)
if !reflect.DeepEqual(value.SelectedLanes, []string{"alpha", "beta", "inactive"}) || len(value.Contexts) != 1 || len(value.Contexts[0].Units) != 3 { if actual := []int{value[0].ID, value[1].ID, value[2].ID}; !reflect.DeepEqual(actual, []int{1, 2, 3}) {
t.Fatalf("evidence context = %#v, want selected union", value) t.Fatalf("evidence context = %#v, want selected union", value)
} }
if got := value.Contexts[0].EvidenceRefs; len(got) != 2 || got[0].LaneID != "alpha" || got[1].LaneID != "beta" {
t.Fatalf("evidence refs = %#v, want both selected lanes", got)
}
debugJSON := string(debug.json["output/evidence-context.json"]) debugJSON := string(debug.json["output/evidence-context.json"])
if strings.Contains(debugJSON, "text-1") || strings.Contains(debugJSON, "metadata") || !strings.Contains(debugJSON, `"artifact_kind":"source/evidence-context"`) || !strings.Contains(debugJSON, `"schema_id":"notarius.source.evidence_context"`) || !strings.Contains(debugJSON, `"context_count":1`) || !strings.Contains(debugJSON, `"unit_count":3`) { if strings.Contains(debugJSON, "text-1") || strings.Contains(debugJSON, "metadata") || !strings.Contains(debugJSON, `"artifact_kind":"source/evidence-context"`) || !strings.Contains(debugJSON, `"schema_id":"notarius.source.evidence_context"`) || strings.Contains(debugJSON, "context_count") || !strings.Contains(debugJSON, `"unit_count":3`) {
t.Fatalf("evidence debug envelope = %s, want only allowlisted summary", debugJSON) t.Fatalf("evidence debug envelope = %s, want only allowlisted summary", debugJSON)
} }
} }
@@ -134,7 +131,7 @@ func TestRunnerEvidenceContextOmitsAbsentAndRejectedLanes(t *testing.T) {
t.Fatalf("rejections = %#v, want rejected lane unchanged", result.Rejected) t.Fatalf("rejections = %#v, want rejected lane unchanged", result.Rejected)
} }
value := decodeCapturedEvidence(t, encoder) value := decodeCapturedEvidence(t, encoder)
if len(value.Contexts) != 1 || len(value.Contexts[0].EvidenceRefs) != 1 || value.Contexts[0].EvidenceRefs[0].LaneID != "present" { if len(value) != 1 || value[0].ID != 1 {
t.Fatalf("evidence context = %#v, want present lane only", value) t.Fatalf("evidence context = %#v, want present lane only", value)
} }
} }
@@ -232,7 +229,7 @@ func TestRunnerEvidenceContextRebuildsFromAcceptedCheckpoint(t *testing.T) {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
value := decodeCapturedEvidence(t, encoder) value := decodeCapturedEvidence(t, encoder)
if len(value.Contexts) != 1 || value.Contexts[0].EvidenceRefs[0].LaneID != "notes" { if len(value) != 1 || value[0].ID != 1 {
t.Fatalf("evidence context = %#v, want checkpointed normalized output", value) t.Fatalf("evidence context = %#v, want checkpointed normalized output", value)
} }
} }

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: "semantic repeated members", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 1}, "canonical_candidate_id": 1}}}, valid: true},
{name: "zero member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{0, 1}, "canonical_candidate_id": 1}}}, valid: false},
{name: "negative member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{-1, 1}, "canonical_candidate_id": 1}}}, valid: false},
{name: "non-integer member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2.5}, "canonical_candidate_id": 1}}}, valid: false},
{name: "zero canonical", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 0}}}, valid: false},
{name: "contextual selectors", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 1, "source_refs": []any{}}}}, valid: false},
}
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 rendered[index] = message.Content
} }
content := strings.Join(rendered, "\n") 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) { if !strings.Contains(content, field) {
t.Fatalf("prepared prompt does not include shared evidence field %q", field) t.Fatalf("prepared prompt does not include shared evidence field %q", field)
} }
} }
if strings.Contains(content, "start_segment") || strings.Contains(content, "end_segment") { for _, obsolete := range []string{"source_id", "start_segment", "end_segment"} {
t.Fatalf("prepared prompt contains obsolete segment evidence fields: %s", content) 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 ( import (
"context" "context"
"errors"
"fmt" "fmt"
"reflect" "reflect"
"sort" "sort"
@@ -13,20 +12,19 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity" "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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
) )
const ( const (
Key = "dnd/item-registry" Key = "dnd/item-registry"
PromptID = "dnd.item_registry.normalize" PromptID = "dnd.item_registry.normalize"
normalizationPolicy = "dnd.item_registry.normalize.v2" PromptVersion = "v1"
semanticContextPolicy = "dnd.entity_reconcile.context.v1" normalizationPolicy = "dnd.item_registry.normalize.v3"
semanticContextRadius = 2 NormalizationPolicy = normalizationPolicy
NormalizationPolicy = normalizationPolicy
ReasonCodeItemFieldsNormalized = "item_fields_normalized" ReasonCodeItemFieldsNormalized = "item_fields_normalized"
ReasonCodeItemIDRecomputed = "item_id_recomputed" ReasonCodeItemIDRecomputed = "item_id_recomputed"
@@ -47,9 +45,7 @@ var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
type Options struct{} type Options struct{}
type Normalizer struct { type Normalizer struct {
llm contracts.StructuredLLMClient engine *semanticreconcile.Engine
promptSHA string
responseSchemaSHA string
} }
func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error) { func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error) {
@@ -60,46 +56,45 @@ func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error
if err != nil { if err != nil {
return nil, normalizerErrorf("load prompt metadata: %w", err) 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 { 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) Key() string { return Key }
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (n *Normalizer) ManifestMetadata() map[string]any { func (n *Normalizer) ManifestMetadata() map[string]any {
if n == nil { if n == nil || n.engine == nil {
return nil return nil
} }
return map[string]any{ metadata := n.engine.ManifestMetadata()
"prompt_id": PromptID, "prompt_version": entityreconcile.SchemaVersion, "prompt_sha256": n.promptSHA, metadata["identity_policy"] = identity.Policy
"response_schema_key": string(entityreconcile.ResponseSchemaKey), "response_schema_id": entityreconcile.ResponseSchemaID, metadata["normalization_policy"] = normalizationPolicy
"response_schema_name": entityreconcile.ResponseSchemaName, "response_schema_version": entityreconcile.SchemaVersion, return metadata
"response_schema_sha256": n.responseSchemaSHA, "identity_policy": identity.Policy,
"normalization_policy": normalizationPolicy, "semantic_context_policy": semanticContextPolicy, "semantic_context_radius": semanticContextRadius,
}
} }
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if n == nil { if n == nil || n.engine == nil {
return nil return nil
} }
return []pipeline.CheckpointFingerprint{ fingerprints := n.engine.CheckpointFingerprints()
{Name: "prompt", Value: n.promptSHA}, {Name: "response_schema", Value: n.responseSchemaSHA}, return append(fingerprints,
{Name: "identity_policy", Value: identity.Policy}, {Name: "normalization_policy", Value: normalizationPolicy}, pipeline.CheckpointFingerprint{Name: "identity_policy", Value: identity.Policy},
{Name: "semantic_context_policy", Value: fmt.Sprintf("%s:%d", semanticContextPolicy, semanticContextRadius)}, pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy},
} )
} }
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.ItemRegistry]) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) { func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.ItemRegistry]) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) {
if n == nil { if n == nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("normalizer must not be nil") return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("normalizer must not be nil")
} }
if n.llm == nil { if n.engine == nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("LLM client must not be nil") return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("semantic reconciliation engine must not be nil")
} }
if ctx == nil { if ctx == nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("context must not be 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 { if err := ctx.Err(); err != nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("context error before normalize: %w", err) return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("context error before normalize: %w", err)
} }
if req.Source == nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("source document must not be nil")
}
order := shared.NewSourceRefOrder(req.Source) order := shared.NewSourceRefOrder(req.Source)
records, warnings := preprocessRecords(req.MergeOutput.Value, order) records, warnings := preprocessRecords(req.MergeOutput.Value, order)
deterministic := recordList(records) deterministic := recordList(records)
materials, ready, err := entityreconcile.BuildContext(req.Source, reconciliationCandidates(records), semanticContextRadius) if len(records) < 2 {
if err != nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("build semantic context: %w", err)
}
if !ready {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
} }
var response entityreconcile.ProposalResponse candidates, envelopes, err := reconciliationInputs(records)
if _, err := n.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ if err != nil {
StageName: Key, PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, 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, ProfileID: req.LLMProfile, SessionID: req.SessionID,
Inputs: contracts.LLMInputSet{"candidates": materials.Candidates, "transcript": materials.Transcript}, })
}, &response); err != nil { if err != nil {
if errors.Is(err, contracts.ErrInvalidStructuredOutput) { return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("reconcile semantic duplicates: %w", err)
return n.invalidStructuredResult(deterministic, warnings), nil }
}
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("complete structured output: %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())
}
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...) warnings = append(warnings, semanticWarnings...)
if rejectedGroups > 0 { discardedGroups := reconciliation.DiscardedGroupCount() + rejectedGroups
return currencyRetryResult(recordList(applied), warnings, rejectedGroups), nil if discardedGroups == 0 {
}
if assessment.DiscardedGroups() == 0 {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: recordList(applied), Warnings: limitWarnings(warnings)}, nil 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] { 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] { 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")
}
discardedGroups := reconciliation.DiscardedGroupCount() + rejectedGroups
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: value, Warnings: limitWarningsForRetry(warnings), Retry: &contracts.NormalizeRetry{ return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: value, Warnings: limitWarningsForRetry(warnings), Retry: &contracts.NormalizeRetry{
ReasonCode: ReasonCodeItemSemanticProposalInvalid, Message: diagnostics.Aggregate("semantic proposal requires retry", reconciliationIssues(assessment)), ReasonCode: ReasonCodeItemSemanticProposalInvalid, Message: diagnostics.Aggregate("semantic proposal requires retry", details),
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(assessment.DiscardedGroups())}, FallbackWarnings: []contracts.Warning{semanticFallbackWarning(discardedGroups)},
}}
}
func currencyRetryResult(value dnd.ItemRegistry, warnings []contracts.Warning, rejectedGroups int) contracts.TypedNormalizeResult[dnd.ItemRegistry] {
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)},
}} }}
} }
@@ -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)}) 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 { type normalizedRecord struct {
item dnd.Item item dnd.Item
inputIndexes []int inputIndexes []int

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
@@ -14,10 +15,10 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity" "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/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
identityvalidator "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemregistry/identity" identityvalidator "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemregistry/identity"
"gitea.maximumdirect.net/eric/promptkit" "gitea.maximumdirect.net/eric/promptkit"
) )
@@ -35,9 +36,25 @@ func TestModuleContractAndMetadata(t *testing.T) {
t.Fatal("New() accepted nil client") t.Fatal("New() accepted nil client")
} }
metadata := newNormalizer(t, &recordingNormalizerClient{}).ManifestMetadata() 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) 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) { func TestNormalizeConsolidatesEqualNamesAcrossEvidenceWithoutMutation(t *testing.T) {
@@ -73,10 +90,10 @@ func TestNormalizeConsolidatesEqualNamesAcrossEvidenceWithoutMutation(t *testing
} }
var candidates struct { var candidates struct {
Candidates []struct { Candidates []struct {
Name string `json:"name"` Label string `json:"label"`
} `json:"candidates"` } `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) 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)) 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: "Compass of the Stars", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
{Name: "Gold Pieces", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, {Name: "Gold Pieces", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}} }}
client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"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)) result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry != nil || len(result.Value.Items) != 2 { if err != nil || result.Retry != nil || len(result.Value.Items) != 2 {
t.Fatalf("Normalize() = %#v, %v", result, err) t.Fatalf("Normalize() = %#v, %v", result, err)
@@ -142,7 +159,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Piece", SourceRefs: ref(20)}, {Name: "Gold Piece", SourceRefs: ref(20)},
{Name: "Gold Pieces", SourceRefs: ref(30)}, {Name: "Gold Pieces", SourceRefs: ref(30)},
}, },
response: `{"duplicate_groups":[{"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"}, wantNames: []string{"Gold Piece"},
wantRefCounts: []int{3}, wantRefCounts: []int{3},
warning: ReasonCodeDuplicateItemCollapsed, warning: ReasonCodeDuplicateItemCollapsed,
@@ -153,7 +170,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Pieces", SourceRefs: ref(10)}, {Name: "Gold Pieces", SourceRefs: ref(10)},
{Name: "Silver Pieces", SourceRefs: ref(20)}, {Name: "Silver Pieces", SourceRefs: ref(20)},
}, },
response: `{"duplicate_groups":[{"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"}, wantNames: []string{"Gold Pieces", "Silver Pieces"},
wantRefCounts: []int{1, 1}, wantRefCounts: []int{1, 1},
wantRetry: true, wantRetry: true,
@@ -165,7 +182,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Pieces", SourceRefs: ref(10)}, {Name: "Gold Pieces", SourceRefs: ref(10)},
{Name: "Longsword", SourceRefs: ref(20)}, {Name: "Longsword", SourceRefs: ref(20)},
}, },
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`, response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1}]}`,
wantNames: []string{"Gold Pieces", "Longsword"}, wantNames: []string{"Gold Pieces", "Longsword"},
wantRefCounts: []int{1, 1}, wantRefCounts: []int{1, 1},
wantRetry: true, wantRetry: true,
@@ -177,7 +194,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Star Compass", SourceRefs: ref(10)}, {Name: "Star Compass", SourceRefs: ref(10)},
{Name: "Compass of the Stars", SourceRefs: ref(20)}, {Name: "Compass of the Stars", SourceRefs: ref(20)},
}, },
response: `{"duplicate_groups":[{"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"}, wantNames: []string{"Compass of the Stars"},
wantRefCounts: []int{2}, wantRefCounts: []int{2},
warning: ReasonCodeDuplicateItemCollapsed, warning: ReasonCodeDuplicateItemCollapsed,
@@ -188,7 +205,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Pieces", SourceRefs: ref(10)}, {Name: "Gold Pieces", SourceRefs: ref(10)},
{Name: "Longsword", SourceRefs: ref(20)}, {Name: "Longsword", SourceRefs: ref(20)},
}, },
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`, response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`,
wantNames: []string{"Gold Pieces", "Longsword"}, wantNames: []string{"Gold Pieces", "Longsword"},
wantRefCounts: []int{1, 1}, wantRefCounts: []int{1, 1},
wantRetry: true, wantRetry: true,
@@ -231,13 +248,67 @@ func TestNormalizePreservesCandidatesForUnsafeProposalGroups(t *testing.T) {
{Name: "Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}, {Name: "Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
{Name: "Rope", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, {Name: "Rope", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}} }}
client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"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)) result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry == nil || len(result.Value.Items) != 3 || !strings.Contains(result.Retry.Message, "overlapping_member") || !strings.Contains(result.Retry.Message, "canonical_unknown") || strings.Contains(result.Retry.Message, "Star Compass") || len(result.Retry.Message) > 4096 { if err != nil || result.Retry == nil || len(result.Value.Items) != 3 || !strings.Contains(result.Retry.Message, "overlapping_member") || !strings.Contains(result.Retry.Message, "canonical_unknown") || strings.Contains(result.Retry.Message, "Star Compass") || len(result.Retry.Message) > 4096 {
t.Fatalf("Normalize() = %#v, %v; want deterministic retry fallback", result, err) t.Fatalf("Normalize() = %#v, %v; want deterministic retry fallback", result, err)
} }
} }
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) { func TestNormalizeRetryFallbackErrorsWarningsAndIdempotence(t *testing.T) {
doc := semanticDocument() 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}}}}} 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) { func TestRegisterPromptAssetsPreparesItemNormalizationPrompt(t *testing.T) {
registry := llm.NewAssetRegistry() registry := llm.NewAssetRegistry()
if err := entityreconcile.RegisterSchemaAssets(registry); err != nil { if err := semanticreconcile.RegisterAssets(registry); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := RegisterPromptAssets(registry); err != nil { if err := RegisterPromptAssets(registry); err != nil {
@@ -276,13 +347,16 @@ func TestRegisterPromptAssetsPreparesItemNormalizationPrompt(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: 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 { if err != nil {
t.Fatal(err) 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) 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 { type recordingNormalizerClient struct {
@@ -300,53 +374,13 @@ func (c *recordingNormalizerClient) CompleteStructured(_ context.Context, reques
if response == "" { if response == "" {
response = `{"duplicate_groups":[]}` response = `{"duplicate_groups":[]}`
} }
content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content) content := []byte(response)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
if err := json.Unmarshal(content, output); err != nil { if err := json.Unmarshal(content, output); err != nil {
return contracts.StructuredCompletionResponse{}, err return contracts.StructuredCompletionResponse{}, err
} }
return contracts.StructuredCompletionResponse{Content: content}, nil 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 { func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {
t.Helper() t.Helper()
normalizer, err := New(client, Options{}) normalizer, err := New(client, Options{})
@@ -356,7 +390,10 @@ func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normaliz
return normalizer return normalizer
} }
func normalizeRequest(value dnd.ItemRegistry) contracts.TypedNormalizeRequest[dnd.ItemRegistry] { func normalizeRequest(value dnd.ItemRegistry) contracts.TypedNormalizeRequest[dnd.ItemRegistry] {
return contracts.TypedNormalizeRequest[dnd.ItemRegistry]{MergeOutput: contracts.MergeArtifact[dnd.ItemRegistry]{Value: value}} return contracts.TypedNormalizeRequest[dnd.ItemRegistry]{
Source: &source.SourceDocument{},
MergeOutput: contracts.MergeArtifact[dnd.ItemRegistry]{Value: value},
}
} }
func normalizeRequestWithSource(value dnd.ItemRegistry, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.ItemRegistry] { func normalizeRequestWithSource(value dnd.ItemRegistry, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.ItemRegistry] {
request := normalizeRequest(value) request := normalizeRequest(value)
@@ -374,3 +411,12 @@ func hasWarning(warnings []contracts.Warning, reason string) bool {
} }
return false 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" rootassets "gitea.maximumdirect.net/eric/notarius/assets"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs" "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" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
const promptAssetRoot = "assets/prompts" const promptAssetRoot = "assets/prompts"
var promptAssetManifest = shared.PromptAssetManifest{ func promptAssetManifest() (shared.PromptAssetManifest, error) {
ModuleDir: PromptID, sharedFiles, err := semanticreconcile.SharedPromptFiles()
ModuleFiles: []promptfs.ModulePromptFile{ if err != nil {
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"}, return shared.PromptAssetManifest{}, fmt.Errorf("load shared semantic reconciliation prompt assets: %w", err)
{Name: "instructions.md", Path: "prompts/instructions.md"}, }
{Name: "candidates.md", Path: "prompts/candidates.md"}, return shared.PromptAssetManifest{
}, ModuleDir: PromptID,
SharedFiles: []string{"common-dnd-system.md", "common-dnd-entity-reconciliation.md", "common-dnd-transcript-windows.md"}, ModuleFiles: []promptfs.ModulePromptFile{
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
{Name: "instructions.md", Path: "prompts/instructions.md"},
},
SharedFiles: []string{"common-dnd-system.md"},
ExternalSharedFiles: sharedFiles,
}, nil
} }
func moduleAssetFS() (fs.FS, error) { func moduleAssetFS() (fs.FS, error) {
@@ -36,7 +43,11 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
if err != nil { if err != nil {
return err return err
} }
promptFS, err := promptAssetManifest.PromptFS(assets) manifest, err := promptAssetManifest()
if err != nil {
return err
}
promptFS, err := manifest.PromptFS(assets)
if err != nil { if err != nil {
return fmt.Errorf("prepare item normalization prompt assets: %w", err) return fmt.Errorf("prepare item normalization prompt assets: %w", err)
} }
@@ -50,7 +61,12 @@ func promptAssetMetadata() (string, error) {
promptAssetHashErr = err promptAssetHashErr = err
return return
} }
promptAssetHash, promptAssetHashErr = promptAssetManifest.Hash(assets) manifest, err := promptAssetManifest()
if err != nil {
promptAssetHashErr = err
return
}
promptAssetHash, promptAssetHashErr = manifest.Hash(assets)
}) })
return promptAssetHash, promptAssetHashErr return promptAssetHash, promptAssetHashErr
} }

View File

@@ -2,104 +2,106 @@ package itemregistry
import ( import (
"fmt" "fmt"
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "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/items/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "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/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
) )
type safeReconciliationGroup struct { const incompatibleCurrencyGroup semanticreconcile.RejectionCategory = "incompatible_currency"
members []int
canonical int
}
func reconciliationCandidates(records []normalizedRecord) []entityreconcile.Candidate { func reconciliationInputs(records []normalizedRecord) ([]semanticreconcile.Candidate, []semanticreconcile.Record[dnd.Item], error) {
candidates := make([]entityreconcile.Candidate, len(records)) candidates := make([]semanticreconcile.Candidate, len(records))
envelopes := make([]semanticreconcile.Record[dnd.Item], len(records))
for index, record := range 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),
}
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 return candidates, envelopes, nil
} }
func reconciliationGroups(assessment entityreconcile.Assessment, candidateKeys []string) []safeReconciliationGroup { func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRecord, envelopes []semanticreconcile.Record[dnd.Item], order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning, int, error) {
positions := make(map[string]int, len(candidateKeys)) application, err := semanticreconcile.ApplyPlan(plan, envelopes, semanticreconcile.ApplicationPolicy[dnd.Item]{
for index, key := range candidateKeys { CloneValue: cloneItem,
positions[key] = index RejectGroup: func(members []dnd.Item, _ dnd.Item) semanticreconcile.RejectionCategory {
} if !canConsolidate(members) {
safeGroups := assessment.SafeGroups() return incompatibleCurrencyGroup
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 return ""
} },
canonical, ok := positions[group.Canonical()] ConsolidateGroup: func(members []dnd.Item, canonical dnd.Item) (dnd.Item, error) {
if valid && ok { output := cloneItem(canonical)
groups = append(groups, safeReconciliationGroup{members: memberPositions, canonical: canonical}) output.SourceRefs = nil
} for _, member := range members {
} output.SourceRefs = append(output.SourceRefs, member.SourceRefs...)
return groups
}
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)
}
return details
}
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"}) output.SourceRefs = order.Canonicalize(output.SourceRefs)
rejectedGroups++ output.ID = identity.DeriveID(output.Name)
continue return output, nil
} },
consolidated := consolidateSemanticGroup(records, group, order) })
output = append(output, consolidated) if err != nil {
warnings = append(warnings, semanticDuplicateWarning(consolidated, records[group.canonical])) return nil, nil, 0, err
} }
return output, warnings, rejectedGroups
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(),
}
}
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 canConsolidate(records []normalizedRecord, group safeReconciliationGroup) bool { func canConsolidate(items []dnd.Item) bool {
denomination := "" denomination := ""
hasCurrency := false hasCurrency := false
hasNonCurrency := false hasNonCurrency := false
hasConflictingDenominations := false hasConflictingDenominations := false
for _, member := range group.members { for _, item := range items {
current := currencyDenomination(records[member].item.Name) current := currencyDenomination(item.Name)
if current == "" { if current == "" {
hasNonCurrency = true hasNonCurrency = true
continue continue
@@ -131,29 +133,18 @@ func currencyDenomination(name string) string {
} }
} }
func consolidateSemanticGroup(records []normalizedRecord, group safeReconciliationGroup, order shared.SourceRefOrder) normalizedRecord { func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) contracts.Warning {
output := cloneRecord(records[group.members[0]]) inputIndexes := provenance.OriginalInputIndexes()
output.item.Name = records[group.canonical].item.Name details := make([]string, 0, len(inputIndexes)+1)
for _, member := range group.members[1:] { for _, inputIndex := range inputIndexes {
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 {
details = append(details, fmt.Sprintf("input index %d", inputIndex)) 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)) 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 ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"reflect" "reflect"
"sort" "sort"
@@ -14,20 +13,19 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" "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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
) )
const ( const (
Key = "dnd/location-registry" Key = "dnd/location-registry"
PromptID = "dnd.location_registry.normalize" PromptID = "dnd.location_registry.normalize"
normalizationPolicy = "dnd.location_registry.normalize.v2" PromptVersion = "v1"
semanticContextPolicy = "dnd.entity_reconcile.context.v1" normalizationPolicy = "dnd.location_registry.normalize.v3"
semanticContextRadius = 2 NormalizationPolicy = normalizationPolicy
NormalizationPolicy = normalizationPolicy
ReasonCodeLocationFieldsNormalized = "location_fields_normalized" ReasonCodeLocationFieldsNormalized = "location_fields_normalized"
ReasonCodeLocationIDRecomputed = "location_id_recomputed" ReasonCodeLocationIDRecomputed = "location_id_recomputed"
@@ -48,9 +46,7 @@ var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
type Options struct{} type Options struct{}
type Normalizer struct { type Normalizer struct {
llm contracts.StructuredLLMClient engine *semanticreconcile.Engine
promptSHA string
responseSchemaSHA string
} }
func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error) { func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error) {
@@ -61,46 +57,45 @@ func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error
if err != nil { if err != nil {
return nil, normalizerErrorf("load prompt metadata: %w", err) 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 { 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) Key() string { return Key }
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (n *Normalizer) ManifestMetadata() map[string]any { func (n *Normalizer) ManifestMetadata() map[string]any {
if n == nil { if n == nil || n.engine == nil {
return nil return nil
} }
return map[string]any{ metadata := n.engine.ManifestMetadata()
"prompt_id": PromptID, "prompt_version": entityreconcile.SchemaVersion, "prompt_sha256": n.promptSHA, metadata["identity_policy"] = identity.Policy
"response_schema_key": string(entityreconcile.ResponseSchemaKey), "response_schema_id": entityreconcile.ResponseSchemaID, metadata["normalization_policy"] = normalizationPolicy
"response_schema_name": entityreconcile.ResponseSchemaName, "response_schema_version": entityreconcile.SchemaVersion, return metadata
"response_schema_sha256": n.responseSchemaSHA, "identity_policy": identity.Policy,
"normalization_policy": normalizationPolicy, "semantic_context_policy": semanticContextPolicy, "semantic_context_radius": semanticContextRadius,
}
} }
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if n == nil { if n == nil || n.engine == nil {
return nil return nil
} }
return []pipeline.CheckpointFingerprint{ fingerprints := n.engine.CheckpointFingerprints()
{Name: "prompt", Value: n.promptSHA}, {Name: "response_schema", Value: n.responseSchemaSHA}, return append(fingerprints,
{Name: "identity_policy", Value: identity.Policy}, {Name: "normalization_policy", Value: normalizationPolicy}, pipeline.CheckpointFingerprint{Name: "identity_policy", Value: identity.Policy},
{Name: "semantic_context_policy", Value: fmt.Sprintf("%s:%d", semanticContextPolicy, semanticContextRadius)}, pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy},
} )
} }
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.LocationRegistry]) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) { func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.LocationRegistry]) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) {
if n == nil { if n == nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("normalizer must not be nil") return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("normalizer must not be nil")
} }
if n.llm == nil { if n.engine == nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("LLM client must not be nil") return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("semantic reconciliation engine must not be nil")
} }
if ctx == nil { if ctx == nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("context must not be 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 { if err := ctx.Err(); err != nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("context error before normalize: %w", err) return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("context error before normalize: %w", err)
} }
if req.Source == nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("source document must not be nil")
}
order := shared.NewSourceRefOrder(req.Source) order := shared.NewSourceRefOrder(req.Source)
records, warnings := preprocessRecords(req.MergeOutput.Value, order) records, warnings := preprocessRecords(req.MergeOutput.Value, order)
deterministic := recordList(records) deterministic := recordList(records)
materials, ready, err := entityreconcile.BuildContext(req.Source, reconciliationCandidates(records), semanticContextRadius) if len(records) < 2 {
if err != nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("build semantic context: %w", err)
}
if !ready {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil return contracts.TypedNormalizeResult[dnd.LocationRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
} }
var response entityreconcile.ProposalResponse candidates, envelopes, err := reconciliationInputs(records)
if _, err := n.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ if err != nil {
StageName: Key, PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("prepare semantic reconciliation inputs: %w", err)
ProfileID: req.LLMProfile, SessionID: req.SessionID, }
Inputs: contracts.LLMInputSet{"candidates": materials.Candidates, "transcript": materials.Transcript}, reconciliation, err := n.engine.Reconcile(ctx, semanticreconcile.Request{
}, &response); err != nil { StageName: Key, Source: req.Source, Candidates: candidates,
if errors.Is(err, contracts.ErrInvalidStructuredOutput) { ProfileID: req.LLMProfile, SessionID: req.SessionID,
return n.invalidStructuredResult(deterministic, warnings), nil })
} if err != nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("complete structured output: %w", err) 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())
}
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...) 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 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] { 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{ return contracts.TypedNormalizeResult[dnd.LocationRegistry]{Value: value, Warnings: limitWarningsForRetry(warnings), Retry: &contracts.NormalizeRetry{
ReasonCode: ReasonCodeLocationSemanticProposalInvalid, Message: diagnostics.Aggregate("semantic proposal requires retry", reconciliationIssues(assessment)), ReasonCode: ReasonCodeLocationSemanticProposalInvalid, Message: diagnostics.Aggregate("semantic proposal requires retry", semanticreconcile.IssueDetails(reconciliation.Issues())),
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(assessment.DiscardedGroups())}, 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)}) 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 { type normalizedRecord struct {
location dnd.Location location dnd.Location
inputIndexes []int inputIndexes []int

View File

@@ -2,7 +2,9 @@ package locationregistry
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt"
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
@@ -11,9 +13,10 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" "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) { func TestModuleContractAndMetadata(t *testing.T) {
@@ -30,11 +33,24 @@ func TestModuleContractAndMetadata(t *testing.T) {
} }
normalizer := newNormalizer(t, &recordingLocationNormalizerClient{}) normalizer := newNormalizer(t, &recordingLocationNormalizerClient{})
metadata := normalizer.ManifestMetadata() 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) 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" { for _, name := range []string{"prompt", "response_schema", "semantic_reconciliation_policy", "semantic_reconciliation_limits", "identity_policy", "normalization_policy"} {
t.Fatalf("fingerprints = %#v", got) 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}}}, {Name: "The Tavern Cellar", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}},
}} }}
before := dnd.LocationRegistry{Locations: append([]dnd.Location(nil), input.Locations...)} 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 { if err != nil || len(result.Value.Locations) != 3 {
t.Fatalf("Normalize() = %#v, %v; want one exact duplicate removed", result, err) 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"}) { 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) 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) 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) { 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() doc := semanticDocument()
input := dnd.LocationRegistry{Locations: []dnd.Location{ input := dnd.LocationRegistry{Locations: []dnd.Location{
{Name: "Old Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {Name: "Old Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
@@ -107,7 +124,7 @@ func TestNormalizeRejectsUnsafeAndOverlappingGroupsWithoutLosingCandidates(t *te
{Name: "Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}, {Name: "Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
{Name: "Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, {Name: "Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}} }}
client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"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)) result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry == nil || len(result.Value.Locations) != 3 || !strings.Contains(result.Retry.Message, "overlapping_member") { if err != nil || result.Retry == nil || len(result.Value.Locations) != 3 || !strings.Contains(result.Retry.Message, "overlapping_member") {
t.Fatalf("Normalize() = %#v, %v; want safe retry fallback", result, err) t.Fatalf("Normalize() = %#v, %v; want safe retry fallback", result, err)
@@ -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) { func TestNormalizeHandlesRetryFallbackAndErrors(t *testing.T) {
doc := semanticDocument() 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}}}}} 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) 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" rootassets "gitea.maximumdirect.net/eric/notarius/assets"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs" "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" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
const promptAssetRoot = "assets/prompts" const promptAssetRoot = "assets/prompts"
var promptAssetManifest = shared.PromptAssetManifest{ func promptAssetManifest() (shared.PromptAssetManifest, error) {
ModuleDir: PromptID, sharedFiles, err := semanticreconcile.SharedPromptFiles()
ModuleFiles: []promptfs.ModulePromptFile{ if err != nil {
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"}, return shared.PromptAssetManifest{}, fmt.Errorf("load shared semantic reconciliation prompt assets: %w", err)
{Name: "instructions.md", Path: "prompts/instructions.md"}, }
{Name: "candidates.md", Path: "prompts/candidates.md"}, return shared.PromptAssetManifest{
}, ModuleDir: PromptID,
SharedFiles: []string{"common-dnd-system.md", "common-dnd-entity-reconciliation.md", "common-dnd-transcript-windows.md"}, ModuleFiles: []promptfs.ModulePromptFile{
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
{Name: "instructions.md", Path: "prompts/instructions.md"},
},
SharedFiles: []string{"common-dnd-system.md"},
ExternalSharedFiles: sharedFiles,
}, nil
} }
func moduleAssetFS() (fs.FS, error) { func moduleAssetFS() (fs.FS, error) {
@@ -36,7 +43,11 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
if err != nil { if err != nil {
return err return err
} }
promptFS, err := promptAssetManifest.PromptFS(assets) manifest, err := promptAssetManifest()
if err != nil {
return err
}
promptFS, err := manifest.PromptFS(assets)
if err != nil { if err != nil {
return fmt.Errorf("prepare location normalization prompt assets: %w", err) return fmt.Errorf("prepare location normalization prompt assets: %w", err)
} }
@@ -50,7 +61,12 @@ func promptAssetMetadata() (string, error) {
promptAssetHashErr = err promptAssetHashErr = err
return return
} }
promptAssetHash, promptAssetHashErr = promptAssetManifest.Hash(assets) manifest, err := promptAssetManifest()
if err != nil {
promptAssetHashErr = err
return
}
promptAssetHash, promptAssetHashErr = manifest.Hash(assets)
}) })
return promptAssetHash, promptAssetHashErr return promptAssetHash, promptAssetHashErr
} }

View File

@@ -7,13 +7,13 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "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" "gitea.maximumdirect.net/eric/promptkit"
) )
func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) { func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) {
registry := llm.NewAssetRegistry() registry := llm.NewAssetRegistry()
if err := entityreconcile.RegisterSchemaAssets(registry); err != nil { if err := semanticreconcile.RegisterAssets(registry); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := RegisterPromptAssets(registry); err != nil { if err := RegisterPromptAssets(registry); err != nil {
@@ -28,11 +28,11 @@ func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: 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 { if err != nil {
t.Fatal(err) 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) t.Fatalf("prepared prompt = %#v", prepared)
} }
for _, index := range []int{2, 4} { for _, index := range []int{2, 4} {

View File

@@ -4,109 +4,77 @@ import (
"fmt" "fmt"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "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/locations/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "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/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
) )
type safeReconciliationGroup struct { func reconciliationInputs(records []normalizedRecord) ([]semanticreconcile.Candidate, []semanticreconcile.Record[dnd.Location], error) {
members []int candidates := make([]semanticreconcile.Candidate, len(records))
canonical int envelopes := make([]semanticreconcile.Record[dnd.Location], len(records))
}
func reconciliationCandidates(records []normalizedRecord) []entityreconcile.Candidate {
candidates := make([]entityreconcile.Candidate, len(records))
for index, record := range 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),
}
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 return candidates, envelopes, nil
} }
func reconciliationGroups(assessment entityreconcile.Assessment, candidateKeys []string) []safeReconciliationGroup { func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRecord, envelopes []semanticreconcile.Record[dnd.Location], order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning, error) {
positions := make(map[string]int, len(candidateKeys)) application, err := semanticreconcile.ApplyPlan(plan, envelopes, semanticreconcile.ApplicationPolicy[dnd.Location]{
for index, key := range candidateKeys { CloneValue: cloneLocation,
positions[key] = index ConsolidateGroup: func(members []dnd.Location, canonical dnd.Location) (dnd.Location, error) {
} output := cloneLocation(canonical)
safeGroups := assessment.SafeGroups() output.SourceRefs = nil
groups := make([]safeReconciliationGroup, 0, len(safeGroups)) for _, member := range members {
for _, group := range safeGroups { output.SourceRefs = append(output.SourceRefs, member.SourceRefs...)
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 output.SourceRefs = order.Canonicalize(output.SourceRefs)
} output.ID = identity.DeriveID(output.Name, output.SourceRefs)
canonical, ok := positions[group.Canonical()] return output, nil
if valid && ok { },
groups = append(groups, safeReconciliationGroup{members: memberPositions, canonical: canonical}) })
if err != nil {
return nil, nil, err
}
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 groups 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 reconciliationIssues(assessment entityreconcile.Assessment) []string { func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) contracts.Warning {
issues := assessment.Issues() inputIndexes := provenance.OriginalInputIndexes()
details := make([]string, len(issues)) details := make([]string, 0, len(inputIndexes)+1)
for index, issue := range issues { for _, inputIndex := range inputIndexes {
details[index] = fmt.Sprintf("group %d: %s", issue.GroupIndex, issue.Category)
}
return details
}
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 {
details = append(details, fmt.Sprintf("input index %d", inputIndex)) 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)) 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 ( import (
"context" "context"
"encoding/json" "encoding/json"
"strconv"
"strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
) )
type recordingLocationNormalizerClient struct { type recordingLocationNormalizerClient struct {
@@ -28,53 +25,13 @@ func (c *recordingLocationNormalizerClient) CompleteStructured(_ context.Context
if response == "" { if response == "" {
response = `{"duplicate_groups":[]}` response = `{"duplicate_groups":[]}`
} }
content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content) content := []byte(response)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
if err := json.Unmarshal(content, output); err != nil { if err := json.Unmarshal(content, output); err != nil {
return contracts.StructuredCompletionResponse{}, err return contracts.StructuredCompletionResponse{}, err
} }
return contracts.StructuredCompletionResponse{Content: content}, nil 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 { func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {
t.Helper() t.Helper()
normalizer, err := New(client, Options{}) normalizer, err := New(client, Options{})
@@ -84,7 +41,10 @@ func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normaliz
return normalizer return normalizer
} }
func normalizeRequest(value dnd.LocationRegistry) contracts.TypedNormalizeRequest[dnd.LocationRegistry] { func normalizeRequest(value dnd.LocationRegistry) contracts.TypedNormalizeRequest[dnd.LocationRegistry] {
return contracts.TypedNormalizeRequest[dnd.LocationRegistry]{MergeOutput: contracts.MergeArtifact[dnd.LocationRegistry]{Value: value}} return contracts.TypedNormalizeRequest[dnd.LocationRegistry]{
Source: &source.SourceDocument{},
MergeOutput: contracts.MergeArtifact[dnd.LocationRegistry]{Value: value},
}
} }
func normalizeRequestWithSource(value dnd.LocationRegistry, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.LocationRegistry] { func normalizeRequestWithSource(value dnd.LocationRegistry, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.LocationRegistry] {
request := normalizeRequest(value) request := normalizeRequest(value)

View File

@@ -3,7 +3,6 @@ package npcregistry
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"reflect" "reflect"
"sort" "sort"
@@ -13,19 +12,19 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
) )
const ( const (
Key = "dnd/npc-registry" Key = "dnd/npc-registry"
PromptID = "dnd.npc_registry.normalize" PromptID = "dnd.npc_registry.normalize"
normalizationPolicy = "dnd.npc_registry.normalize.v4" PromptVersion = "v1"
semanticContextPolicy = "dnd.entity_reconcile.context.v1" normalizationPolicy = "dnd.npc_registry.normalize.v5"
NormalizationPolicy = normalizationPolicy NormalizationPolicy = normalizationPolicy
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized" ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
ReasonCodeNPCIDRecomputed = "npc_id_recomputed" ReasonCodeNPCIDRecomputed = "npc_id_recomputed"
@@ -46,9 +45,7 @@ var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
type Options struct{} type Options struct{}
type Normalizer struct { type Normalizer struct {
llm contracts.StructuredLLMClient engine *semanticreconcile.Engine
promptSHA string
responseSchemaSHA string
} }
func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error) { func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error) {
@@ -59,55 +56,45 @@ func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error
if err != nil { if err != nil {
return nil, normalizerErrorf("load prompt metadata: %w", err) 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 { 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) Key() string { return Key }
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (n *Normalizer) ManifestMetadata() map[string]any { func (n *Normalizer) ManifestMetadata() map[string]any {
if n == nil { if n == nil || n.engine == nil {
return nil return nil
} }
return map[string]any{ metadata := n.engine.ManifestMetadata()
"prompt_id": PromptID, metadata["identity_policy"] = identity.Policy
"prompt_version": entityreconcile.SchemaVersion, metadata["normalization_policy"] = normalizationPolicy
"prompt_sha256": n.promptSHA, return metadata
"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,
}
} }
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if n == nil { if n == nil || n.engine == nil {
return nil return nil
} }
return []pipeline.CheckpointFingerprint{ fingerprints := n.engine.CheckpointFingerprints()
{Name: "prompt", Value: n.promptSHA}, return append(fingerprints,
{Name: "response_schema", Value: n.responseSchemaSHA}, pipeline.CheckpointFingerprint{Name: "identity_policy", Value: identity.Policy},
{Name: "identity_policy", Value: identity.Policy}, pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy},
{Name: "normalization_policy", Value: normalizationPolicy}, )
{Name: "semantic_context_policy", Value: fmt.Sprintf("%s:%d", semanticContextPolicy, semanticContextRadius)},
}
} }
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCRegistry]) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) { func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCRegistry]) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) {
if n == nil { if n == nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("normalizer must not be nil") return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("normalizer must not be nil")
} }
if n.llm == nil { if n.engine == nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("LLM client must not be nil") return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("semantic reconciliation engine must not be nil")
} }
if ctx == nil { if ctx == nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("context must not be 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 { if err := ctx.Err(); err != nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("context error before normalize: %w", err) return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("context error before normalize: %w", err)
} }
if req.Source == nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("source document must not be nil")
}
order := shared.NewSourceRefOrder(req.Source) order := shared.NewSourceRefOrder(req.Source)
records, warnings := preprocessRecords(req.MergeOutput.Value, order) records, warnings := preprocessRecords(req.MergeOutput.Value, order)
deterministic := recordList(records) deterministic := recordList(records)
materials, ready, err := entityreconcile.BuildContext(req.Source, reconciliationCandidates(records), semanticContextRadius) if len(records) < 2 {
if err != nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("build semantic context: %w", err)
}
if !ready {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
} }
var response entityreconcile.ProposalResponse candidates, envelopes, err := reconciliationInputs(records)
if _, err := n.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ if err != nil {
StageName: Key, PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, 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, ProfileID: req.LLMProfile, SessionID: req.SessionID,
Inputs: contracts.LLMInputSet{"candidates": materials.Candidates, "transcript": materials.Transcript}, })
}, &response); err != nil { if err != nil {
if errors.Is(err, contracts.ErrInvalidStructuredOutput) { return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("reconcile semantic duplicates: %w", err)
return n.invalidStructuredResult(deterministic, warnings), nil
}
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("complete structured output: %w", err)
} }
assessment := materials.Assess(response) switch reconciliation.Disposition() {
applied, semanticWarnings := applySafeGroups(records, reconciliationGroups(assessment, materials.CandidateKeys()), order) 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...) 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 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] { 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]{ return contracts.TypedNormalizeResult[dnd.NPCRegistry]{
Value: value, Value: value,
Warnings: limitWarningsForRetry(warnings), Warnings: limitWarningsForRetry(warnings),
Retry: &contracts.NormalizeRetry{ Retry: &contracts.NormalizeRetry{
ReasonCode: ReasonCodeNPCSemanticProposalInvalid, ReasonCode: ReasonCodeNPCSemanticProposalInvalid,
Message: diagnostics.Aggregate("semantic proposal requires retry", reconciliationIssues(assessment)), Message: diagnostics.Aggregate("semantic proposal requires retry", semanticreconcile.IssueDetails(reconciliation.Issues())),
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(assessment.DiscardedGroups())}, 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 { type normalizedRecord struct {
npc dnd.NPC npc dnd.NPC
inputIndexes []int inputIndexes []int

View File

@@ -4,16 +4,15 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"reflect" "reflect"
"strconv"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "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) { 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") t.Fatal("New(nil, Options{}) error = nil, want nil client rejection")
} }
normalizer := newNormalizer(t, &recordingNPCNormalizerClient{}) 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) 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"}} fingerprints := normalizer.CheckpointFingerprints()
if got := normalizer.CheckpointFingerprints(); !reflect.DeepEqual(got, wantFingerprints) { for _, name := range []string{"prompt", "response_schema", "semantic_reconciliation_policy", "semantic_reconciliation_limits", "identity_policy", "normalization_policy"} {
t.Fatalf("fingerprints = %#v, want %#v", got, wantFingerprints) 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) { func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) {
normalizer := newNormalizer(t, &recordingNPCNormalizerClient{}) normalizer := newNormalizer(t, &recordingNPCNormalizerClient{})
if _, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCRegistry]{}); err == nil || !strings.Contains(err.Error(), "source document must not be nil") {
t.Fatalf("nil source Normalize() error = %v", err)
}
result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCRegistry{NPCs: nil})) result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCRegistry{NPCs: nil}))
if err != nil || result.Value.NPCs != nil { if err != nil || result.Value.NPCs != nil {
t.Fatalf("nil list result = %#v, error = %v", result.Value, err) t.Fatalf("nil list result = %#v, error = %v", result.Value, err)
@@ -157,53 +163,13 @@ func (c *recordingNPCNormalizerClient) CompleteStructured(_ context.Context, req
if response == "" { if response == "" {
response = `{"duplicate_groups":[]}` response = `{"duplicate_groups":[]}`
} }
content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content) content := []byte(response)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
if err := json.Unmarshal(content, output); err != nil { if err := json.Unmarshal(content, output); err != nil {
return contracts.StructuredCompletionResponse{}, err return contracts.StructuredCompletionResponse{}, err
} }
return contracts.StructuredCompletionResponse{Content: content}, nil 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 { func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {
t.Helper() t.Helper()
normalizer, err := New(client, Options{}) 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] { func normalizeRequest(value dnd.NPCRegistry) contracts.TypedNormalizeRequest[dnd.NPCRegistry] {
return contracts.TypedNormalizeRequest[dnd.NPCRegistry]{MergeOutput: contracts.MergeArtifact[dnd.NPCRegistry]{Value: value}} return contracts.TypedNormalizeRequest[dnd.NPCRegistry]{
Source: &source.SourceDocument{},
MergeOutput: contracts.MergeArtifact[dnd.NPCRegistry]{Value: value},
}
} }
func normalizeRequestWithSource(value dnd.NPCRegistry, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.NPCRegistry] { func normalizeRequestWithSource(value dnd.NPCRegistry, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.NPCRegistry] {
@@ -231,3 +200,12 @@ func hasWarning(warnings []contracts.Warning, reason, scope string) bool {
} }
return false 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" rootassets "gitea.maximumdirect.net/eric/notarius/assets"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs" "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" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
const promptAssetRoot = "assets/prompts" const promptAssetRoot = "assets/prompts"
var promptAssetManifest = shared.PromptAssetManifest{ func promptAssetManifest() (shared.PromptAssetManifest, error) {
ModuleDir: PromptID, sharedFiles, err := semanticreconcile.SharedPromptFiles()
ModuleFiles: []promptfs.ModulePromptFile{ if err != nil {
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"}, return shared.PromptAssetManifest{}, fmt.Errorf("load shared semantic reconciliation prompt assets: %w", err)
{Name: "instructions.md", Path: "prompts/instructions.md"}, }
{Name: "candidates.md", Path: "prompts/candidates.md"}, return shared.PromptAssetManifest{
}, ModuleDir: PromptID,
SharedFiles: []string{ ModuleFiles: []promptfs.ModulePromptFile{
"common-dnd-system.md", {Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
"common-dnd-entity-reconciliation.md", {Name: "instructions.md", Path: "prompts/instructions.md"},
"common-dnd-transcript-windows.md", },
}, SharedFiles: []string{"common-dnd-system.md"},
ExternalSharedFiles: sharedFiles,
}, nil
} }
func moduleAssetFS() (fs.FS, error) { func moduleAssetFS() (fs.FS, error) {
@@ -40,7 +43,11 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
if err != nil { if err != nil {
return err return err
} }
promptFS, err := promptAssetManifest.PromptFS(assets) manifest, err := promptAssetManifest()
if err != nil {
return err
}
promptFS, err := manifest.PromptFS(assets)
if err != nil { if err != nil {
return fmt.Errorf("prepare NPC normalization prompt assets: %w", err) return fmt.Errorf("prepare NPC normalization prompt assets: %w", err)
} }
@@ -54,7 +61,12 @@ func promptAssetMetadata() (string, error) {
promptAssetHashErr = err promptAssetHashErr = err
return return
} }
promptAssetHash, promptAssetHashErr = promptAssetManifest.Hash(assets) manifest, err := promptAssetManifest()
if err != nil {
promptAssetHashErr = err
return
}
promptAssetHash, promptAssetHashErr = manifest.Hash(assets)
}) })
return promptAssetHash, promptAssetHashErr return promptAssetHash, promptAssetHashErr
} }

View File

@@ -7,7 +7,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "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" "gitea.maximumdirect.net/eric/promptkit"
) )
@@ -16,8 +16,8 @@ func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) {
t.Fatalf("promptAssetMetadata() = %q, %v; want prompt fingerprint", promptHash, err) t.Fatalf("promptAssetMetadata() = %q, %v; want prompt fingerprint", promptHash, err)
} }
registry := llm.NewAssetRegistry() registry := llm.NewAssetRegistry()
if err := entityreconcile.RegisterSchemaAssets(registry); err != nil { if err := semanticreconcile.RegisterAssets(registry); err != nil {
t.Fatalf("RegisterSchemaAssets() error = %v", err) t.Fatalf("RegisterAssets() error = %v", err)
} }
if err := RegisterPromptAssets(registry); err != nil { if err := RegisterPromptAssets(registry); err != nil {
t.Fatalf("RegisterPromptAssets() error = %v", err) t.Fatalf("RegisterPromptAssets() error = %v", err)
@@ -34,21 +34,27 @@ func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) {
t.Fatalf("NewEngine() error = %v", err) t.Fatalf("NewEngine() error = %v", err)
} }
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{ 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{ 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":[]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`),
}, },
}) })
if err != nil { if err != nil {
t.Fatalf("Prepare() error = %v", err) 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) t.Fatalf("prepared prompt = %#v, want normalization prompt identity and schema", prepared)
} }
if prepared.Messages[0].Role != "system" { if prepared.Messages[0].Role != "system" {
t.Fatalf("initial message role = %q, want system", prepared.Messages[0].Role) t.Fatalf("initial message role = %q, want system", prepared.Messages[0].Role)
} }
if !strings.Contains(prepared.Messages[1].Content, "candidate_id") || !strings.Contains(prepared.Messages[1].Content, "integer") {
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} { for _, index := range []int{2, 4} {
if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral { if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != promptkit.CacheControlEphemeral {
t.Errorf("message %d cache control = %#v, want ephemeral", index, cache) t.Errorf("message %d cache control = %#v, want ephemeral", index, cache)

View File

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

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"math" "math"
"reflect" "reflect"
"strings" "strings"
@@ -11,9 +12,10 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "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) { func TestNormalizeSkipsSemanticCompletionWithoutTwoEligibleCandidates(t *testing.T) {
@@ -31,7 +33,7 @@ func TestNormalizeSkipsSemanticCompletionWithoutTwoEligibleCandidates(t *testing
} }
func TestNormalizeAppliesSafeProposalAndUsesPrivateInputs(t *testing.T) { 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) normalizer := newNormalizer(t, client)
doc := semanticDocument() doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{ 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)) t.Fatalf("completion calls = %d, want one", len(client.requests))
} }
completion := client.requests[0] 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) 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) encoded := string(completion.Inputs["candidates"].Content) + string(completion.Inputs["transcript"].Content)
if strings.Contains(encoded, "npc:sha256:") || strings.Contains(encoded, doc.ID) { if strings.Contains(encoded, "npc:sha256:") || strings.Contains(encoded, doc.ID) {
t.Fatalf("completion inputs leaked private identifiers: %s", encoded) 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) { 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) normalizer := newNormalizer(t, client)
doc := semanticDocument() doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{ input := dnd.NPCRegistry{NPCs: []dnd.NPC{
@@ -137,7 +150,7 @@ func TestNormalizeRedactsContextMaterialFailures(t *testing.T) {
request := normalizeRequestWithSource(input, doc) request := normalizeRequestWithSource(input, doc)
request.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(transcript), "sha256:test", originPath) request.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(transcript), "sha256:test", originPath)
_, err := normalizer.Normalize(context.Background(), request) _, 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) 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"} { 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) { func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) {
client := &recordingNPCNormalizerClient{responses: []string{ client := &recordingNPCNormalizerClient{responses: []string{
`{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"},{"members":["candidate-000003","unknown"],"canonical":"candidate-000003"}]}`, `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2},{"candidate_ids":[3,99],"canonical_candidate_id":3}]}`,
`{"duplicate_groups":[{"members":["candidate-000001","candidate-000003"],"canonical":"candidate-000003"}]}`, `{"duplicate_groups":[{"candidate_ids":[1,3],"canonical_candidate_id":3}]}`,
}} }}
normalizer := newNormalizer(t, client) normalizer := newNormalizer(t, client)
doc := semanticDocument() doc := semanticDocument()
@@ -175,8 +188,8 @@ func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) {
} }
} }
func TestNormalizeRetainsRecordsWhenAProposalUsesAnUnknownDescriptor(t *testing.T) { func TestNormalizeRetainsRecordsWhenAProposalUsesAnUnknownHandle(t *testing.T) {
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","unknown"],"canonical":"candidate-000001"}]}`} client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,99],"canonical_candidate_id":1}]}`}
normalizer := newNormalizer(t, client) normalizer := newNormalizer(t, client)
doc := semanticDocument() doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{ input := dnd.NPCRegistry{NPCs: []dnd.NPC{
@@ -193,32 +206,52 @@ func TestNormalizeRetainsRecordsWhenAProposalUsesAnUnknownDescriptor(t *testing.
} }
} }
func TestReconciliationCandidatesKeepEqualDisplayNamesDistinct(t *testing.T) { func TestReconciliationCandidatesKeepEqualContextualDescriptorsDistinct(t *testing.T) {
doc := semanticDocument() doc := semanticDocument()
records := []normalizedRecord{ 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: 10, EndUnitID: 10}}}},
{npc: dnd.NPC{Name: "The Guard", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}}, {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) candidates, _, err := reconciliationInputs(records)
if err != nil || !ready { if err != nil {
t.Fatalf("BuildContext() = %#v, %t, %v; want ready keyed candidates", materials, ready, err) t.Fatalf("reconciliationInputs() error = %v", err)
} }
keys := materials.CandidateKeys() preparation, err := semanticreconcile.Prepare(doc, candidates, semanticreconcile.DefaultLimits())
if !reflect.DeepEqual(keys, []string{"candidate-000001", "candidate-000002"}) || strings.Count(string(materials.Candidates.Content), `"The Guard"`) != 2 { if err != nil || preparation.Disposition() != semanticreconcile.Ready {
t.Fatalf("candidate keys and inputs = %#v, %s; want distinct equal-display candidates", keys, materials.Candidates.Content) t.Fatalf("Prepare() = %#v, %v; want ready candidates", preparation, err)
} }
var candidateInput struct { 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) t.Fatal(err)
} }
assessment := materials.Assess(entityreconcile.ProposalResponse{DuplicateGroups: []entityreconcile.DuplicateGroup{{ 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" {
Members: candidateInput.Candidates, Canonical: candidateInput.Candidates[1], t.Fatalf("candidate input = %#v, want distinct integer handles for equal descriptors", candidateInput)
}}}) }
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) 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" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcregistry"
scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions" scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions"
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" 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/merge/appendorder"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop" "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 { func registerPromptAssets(assets *llm.AssetRegistry) error {
return runRegistrations([]registration{ 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: "scenes prompt assets", register: func() error { return scenes.RegisterPromptAssets(assets) }},
{name: "spells prompt assets", register: func() error { return spellextract.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) }}, {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 { if _, err := fs.ReadFile(fallbackFS, "dnd-extraction.yaml"); err != nil {
t.Fatalf("fallback profile asset = %v, want registered D&D profile", err) 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, "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, "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}) 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,24 +11,24 @@ import (
) )
// PromptAssetManifest is the ordered set of assets that make up one prompt. // PromptAssetManifest is the ordered set of assets that make up one prompt.
// Module files are addressed in the owning module filesystem; shared files use // Module files are addressed in the owning module filesystem. SharedFiles use
// the names in sharedPromptPaths and are mounted beneath sharedassets. // names in sharedPromptPaths, while ExternalSharedFiles are explicit
// caller-owned descriptors. Both kinds are mounted beneath sharedassets.
type PromptAssetManifest struct { type PromptAssetManifest struct {
ModuleDir string ModuleDir string
ModuleFiles []promptfs.ModulePromptFile ModuleFiles []promptfs.ModulePromptFile
SharedFiles []string SharedFiles []string
ExternalSharedFiles []promptfs.SharedPromptFile
} }
var sharedPromptPaths = map[string]string{ var sharedPromptPaths = map[string]string{
"common-dnd-system.md": "prompts/common-dnd-system.md", "common-dnd-system.md": "prompts/common-dnd-system.md",
"common-dnd-extraction-evidence.md": "prompts/common-dnd-extraction-evidence.md", "common-dnd-extraction-evidence.md": "prompts/common-dnd-extraction-evidence.md",
"common-dnd-identity.md": "prompts/common-dnd-identity.md", "common-dnd-identity.md": "prompts/common-dnd-identity.md",
"common-dnd-transcript-full.md": "prompts/common-dnd-transcript-full.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-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-references.md": "prompts/common-dnd-references.md", "common-dnd-npc-registry.md": "prompts/common-dnd-npc-registry.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) { func sharedAssetFS() (fs.FS, error) {
@@ -40,7 +40,7 @@ func sharedAssetFS() (fs.FS, error) {
} }
func (manifest PromptAssetManifest) PromptFS(moduleFS fs.FS) (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 { if err != nil {
return nil, err 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) { func (manifest PromptAssetManifest) Hash(moduleFS fs.FS) (string, error) {
sharedFiles, err := resolveSharedPromptFiles(manifest.SharedFiles) sharedFiles, err := manifest.sharedPromptFiles()
if err != nil { if err != nil {
return "", err 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)) parts := make([]llm.AssetHashPart, 0, len(manifest.ModuleFiles)+len(sharedFiles))
for _, file := range manifest.ModuleFiles { for _, file := range manifest.ModuleFiles {
parts = append(parts, llm.AssetHashPart{FS: moduleFS, Path: file.Path}) 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) 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) { func resolveSharedPromptFiles(names []string) ([]promptfs.SharedPromptFile, error) {
assets, err := sharedAssetFS() assets, err := sharedAssetFS()
if err != nil { if err != nil {

View File

@@ -22,7 +22,6 @@ func TestPromptAssetManifestPromptFS(t *testing.T) {
"common-dnd-system.md", "common-dnd-system.md",
"common-dnd-transcript-full.md", "common-dnd-transcript-full.md",
"common-dnd-transcript-chunk.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-system.md",
"assets/prompts/dnd.test/sharedassets/common-dnd-transcript-full.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-chunk.md",
"assets/prompts/dnd.test/sharedassets/common-dnd-transcript-windows.md",
} { } {
content, err := fs.ReadFile(fsys, path) content, err := fs.ReadFile(fsys, path)
if err != nil { if err != nil {
@@ -207,3 +205,106 @@ func TestSharedPromptDescriptorsReturnFreshCopies(t *testing.T) {
t.Fatalf("resolveSharedPromptFiles() reused descriptor state: first=%#v second=%#v", first, second) 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

@@ -353,8 +353,8 @@ func TestEncodeIncludesValidatedEvidenceContext(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Decode(evidence context file) error = %v", err) t.Fatalf("Decode(evidence context file) error = %v", err)
} }
if len(value.Contexts) != 0 { if len(value) != 1 || value[0].ID != 7 || value[0].Text != "Source content retained only in the evidence artifact." {
t.Fatalf("evidence context = %#v, want explicit empty contexts", value) t.Fatalf("evidence context = %#v, want the published source-unit array", value)
} }
index := decodeObject(t, fileBytes(t, result.Files, "index.json")) index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
if got, want := index["evidence_context"], map[string]any{ if got, want := index["evidence_context"], map[string]any{
@@ -786,7 +786,8 @@ func acceptedEvidenceContextArtifact(t *testing.T) contracts.SerializedArtifact
} }
document.Digest = digest document.Digest = digest
artifact, err := evidencecontext.Serialize(evidencecontext.BuildRequest{ artifact, err := evidencecontext.Serialize(evidencecontext.BuildRequest{
Source: document, WindowUnits: 3, SelectedLanes: []string{"spells"}, Source: document, WindowUnits: 3,
SourceRefs: []source.SourceRef{{SourceID: "source-1", StartUnitID: 7, EndUnitID: 7}},
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

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

View File

@@ -1,15 +1,18 @@
package register package register
import ( import (
"io/fs"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
) )
func TestRegisterAddsGenericFamily(t *testing.T) { func TestRegisterAddsGenericFamily(t *testing.T) {
registries := completeRegistries() 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) t.Fatalf("Register() error = %v, want nil", err)
} }
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"generic"}) 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" { if output, err := registries.Outputs.Build("json"); err != nil || output.Key() != "json" {
t.Fatalf("build json output = %v, %v; want json implementation", output, err) 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) { func TestRegisterRejectsMissingGenericRegistriesBeforeMutation(t *testing.T) {
@@ -48,7 +76,7 @@ func TestRegisterRejectsMissingGenericRegistriesBeforeMutation(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
registries := completeRegistries() registries := completeRegistries()
test.remove(&registries) test.remove(&registries)
err := Register(registries, nil) err := Register(registries, llm.NewAssetRegistry())
if err == nil || !strings.Contains(err.Error(), test.wantErr) { if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("Register() error = %v, want %q", err, 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) { func TestRegisterReportsDuplicateGenericRegistration(t *testing.T) {
registries := completeRegistries() 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) t.Fatalf("first Register() error = %v, want nil", err)
} }
err := Register(registries, nil) err := Register(registries, assets)
if err == nil || !strings.Contains(err.Error(), "register generic chunker") || !strings.Contains(err.Error(), "already registered") { 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) t.Fatalf("second Register() error = %v, want contextual duplicate error", err)
} }
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"reflect"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -71,20 +72,17 @@ func TestLocationRegistryHandoffProducesOccurrencesAndEvidence(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Decode(evidence context) error = %v", err) t.Fatalf("Decode(evidence context) error = %v", err)
} }
if !locationEvidenceHasLane(evidence, "locations") || !locationEvidenceHasLane(evidence, "occurrences") { if actual := locationEvidenceUnitIDs(evidence); !reflect.DeepEqual(actual, []int{1, 2, 3, 4, 5}) {
t.Fatalf("evidence context = %#v, want registry and occurrence evidence from their own artifacts", evidence) t.Fatalf("evidence context = %#v, want deduplicated registry and occurrence source-unit evidence", evidence)
} }
} }
func locationEvidenceHasLane(document evidencecontext.Document, laneID string) bool { func locationEvidenceUnitIDs(document evidencecontext.Document) []int {
for _, context := range document.Contexts { ids := make([]int, len(document))
for _, reference := range context.EvidenceRefs { for index, unit := range document {
if reference.LaneID == laneID { ids[index] = unit.ID
return true
}
}
} }
return false return ids
} }
func TestLocationOccurrenceConsumerDoesNotRunAfterRejectedRegistry(t *testing.T) { func TestLocationOccurrenceConsumerDoesNotRunAfterRejectedRegistry(t *testing.T) {

View File

@@ -17,6 +17,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/evidencecontext" "gitea.maximumdirect.net/eric/notarius/internal/framework/evidencecontext"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "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"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns" combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry"
@@ -49,12 +50,12 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
t.Fatalf("Prepare() error = %v", err) t.Fatalf("Prepare() error = %v", err)
} }
for name, value := range map[string]string{ for name, value := range map[string]string{
"extract:npc_registry:dnd/npc-registry:mapping_policy": "dnd.npc_registry.extract_mapping.v2", "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: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:normalization_policy": npcnormalize.NormalizationPolicy,
"normalize:npc_registry:dnd/npc-registry:semantic_context_policy": "dnd.entity_reconcile.context.v1:2", "normalize:npc_registry:dnd/npc-registry:semantic_reconciliation_policy": semanticreconcile.Policy,
"extract:spells:dnd/spells:mapping_policy": "dnd.spells.extract_mapping.v2", "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", "extract:combat:dnd/combat-turns:scene_gate_policy": "dnd.combat_turns.scene_gate.v1",
} { } {
assertFingerprintValue(t, prepared.CheckpointFingerprints(), name, value) assertFingerprintValue(t, prepared.CheckpointFingerprints(), name, value)
} }
@@ -63,6 +64,7 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
"extract:npc_registry:dnd/npc-registry:response_schema", "extract:npc_registry:dnd/npc-registry:response_schema",
"normalize:npc_registry:dnd/npc-registry:prompt", "normalize:npc_registry:dnd/npc-registry:prompt",
"normalize:npc_registry:dnd/npc-registry:response_schema", "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:prompt",
"extract:spells:dnd/spells:response_schema", "extract:spells:dnd/spells:response_schema",
"extract:spells:dnd/spells:npc_registry", "extract:spells:dnd/spells:npc_registry",
@@ -277,22 +279,8 @@ func TestProductionDNDOutputPublishesSelectedEvidenceContext(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Decode(evidence context) error = %v", err) t.Fatalf("Decode(evidence context) error = %v", err)
} }
if !reflect.DeepEqual(value.SelectedLanes, []string{"combat", "npc_registry", "spells"}) { if actual := evidenceUnitIDs(value); !reflect.DeepEqual(actual, []int{10, 20}) {
t.Fatalf("selected lanes = %#v, want configured production lanes without scene descriptions", value.SelectedLanes) t.Fatalf("evidence units = %#v, want deduplicated source-position union without scene descriptions", actual)
}
if len(value.Contexts) != 2 || len(value.Contexts[0].Units) != 1 || len(value.Contexts[1].Units) != 1 || value.Contexts[0].Units[0].ID != 10 || value.Contexts[1].Units[0].ID != 20 {
t.Fatalf("evidence contexts = %#v, want source-position union with non-monotonic unit IDs", value.Contexts)
}
firstRefs := value.Contexts[0].EvidenceRefs
if len(firstRefs) != 3 || firstRefs[0].LaneID != "combat" || firstRefs[1].LaneID != "npc_registry" || firstRefs[2].LaneID != "spells" {
t.Fatalf("first context evidence = %#v, want overlapping selected lane references", firstRefs)
}
for _, context := range value.Contexts {
for _, reference := range context.EvidenceRefs {
if reference.LaneID == "scene-descriptions" {
t.Fatalf("evidence refs = %#v, want scene descriptions excluded by allowlist", value.Contexts)
}
}
} }
} }
@@ -310,11 +298,19 @@ func TestProductionDNDOutputCanExplicitlySelectSceneDescriptionEvidence(t *testi
if err != nil { if err != nil {
t.Fatalf("Decode(evidence context) error = %v", err) t.Fatalf("Decode(evidence context) error = %v", err)
} }
if !reflect.DeepEqual(value.SelectedLanes, []string{"scene-descriptions"}) || len(value.Contexts) == 0 || len(value.Contexts[0].EvidenceRefs) == 0 || value.Contexts[0].EvidenceRefs[0].LaneID != "scene-descriptions" { if len(value) == 0 {
t.Fatalf("evidence context = %#v, want explicitly selected scene-description evidence", value) t.Fatalf("evidence context = %#v, want explicitly selected scene-description source units", value)
} }
} }
func evidenceUnitIDs(value evidencecontext.Document) []int {
ids := make([]int, len(value))
for index, unit := range value {
ids[index] = unit.ID
}
return ids
}
func TestGroundedPipelineSkipsCombatForExactNarrativeScene(t *testing.T) { func TestGroundedPipelineSkipsCombatForExactNarrativeScene(t *testing.T) {
registries := productionNPCRegistries(t) registries := productionNPCRegistries(t)
configValue := loadGroundedPipelineConfig(t) configValue := loadGroundedPipelineConfig(t)

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}}}}} payload = map[string]any{"npcs": []any{map[string]any{"name": name, "source_refs": []any{map[string]int{"start_unit_id": client.npcCalls, "end_unit_id": client.npcCalls}}}}}
case npcnormalize.PromptID: case npcnormalize.PromptID:
content, err := contextualReconciliationContent([]byte(`{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`), request.Inputs["candidates"].Content) content := []byte(`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1}]}`)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
if err := json.Unmarshal(content, out); err != nil { if err := json.Unmarshal(content, out); err != nil {
return contracts.StructuredCompletionResponse{}, err return contracts.StructuredCompletionResponse{}, err
} }

View File

@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
"testing" "testing"
@@ -13,12 +12,12 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "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" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcregistry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcregistry"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcregistry" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcregistry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
dndregister "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/register" 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" 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" npcregistrysourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcregistry/source_refs"
genericregister "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/register" 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) t.Fatalf("manifest lane = %#v, want NPC production composition", lane)
} }
normalizerMetadata, ok := lane.Metadata["normalizer"].(map[string]any) 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) t.Fatalf("normalizer metadata = %#v, want identity and normalization policies", lane.Metadata)
} }
var npcOutputFile *contracts.OutputFile var npcOutputFile *contracts.OutputFile
@@ -180,8 +179,8 @@ func TestProductionNPCNormalizationRetryUsesFinalSafeProposal(t *testing.T) {
{Name: "Mira Thorn", SourceRefs: []npcProductionSourceRef{{StartUnitID: 2, EndUnitID: 2}}}, {Name: "Mira Thorn", SourceRefs: []npcProductionSourceRef{{StartUnitID: 2, EndUnitID: 2}}},
{Name: "Hooded Guard", SourceRefs: []npcProductionSourceRef{{StartUnitID: 3, EndUnitID: 3}}}, {Name: "Hooded Guard", SourceRefs: []npcProductionSourceRef{{StartUnitID: 3, EndUnitID: 3}}},
}} }}
partial := []byte(`{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"},{"members":["candidate-000003","unknown"],"canonical":"candidate-000003"}]}`) partial := []byte(`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2},{"candidate_ids":[3,99],"canonical_candidate_id":3}]}`)
safe := []byte(`{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`) safe := []byte(`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`)
for _, test := range []struct { for _, test := range []struct {
name string name string
@@ -267,11 +266,6 @@ func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context,
} }
content = append([]byte(nil), client.normalizeResponses[index]...) 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: default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected fake NPC prompt %q", req.PromptID) 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 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 { func (client *fakeNPCProductionLLMClient) requestCount(promptID string) int {
count := 0 count := 0
for _, request := range client.requests { for _, request := range client.requests {