Compare commits

61 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
f120be1cb4 Add proposed changes to shared LLM prompts 2026-08-09 09:18:36 -05:00
17673d74ea Archive the completed codebase audit 2026-08-09 13:24:12 +00:00
ef19a03cbf Reconcile remediation documentation 2026-08-09 02:45:22 +00:00
0546f6eb4f Simplify module cleanup paths 2026-08-09 02:40:25 +00:00
d28d1062e0 Reuse canonical item occurrence evidence 2026-08-09 02:36:53 +00:00
b3ebfcef37 Index enemy event duplicate identities 2026-08-09 02:31:28 +00:00
b70d9f77e3 Improve D&D registry normalization efficiency 2026-08-09 02:26:49 +00:00
2a75f40871 Bound D&D normalization diagnostics 2026-08-09 02:22:23 +00:00
a705ba74a1 Project spell aliases into extraction prompts 2026-08-09 02:17:15 +00:00
8d9c9e7c87 Align item occurrence evidence fields 2026-08-09 02:11:27 +00:00
0b5cc4f251 Require chunk-local extraction evidence 2026-08-09 02:08:11 +00:00
82ffe85f2d Enforce durable enemy event validation 2026-08-09 02:03:27 +00:00
b3644abc0e Enforce durable D&D evidence ranges 2026-08-09 01:54:43 +00:00
d653bf1b90 Share immutable in-memory filesystems 2026-08-09 01:47:44 +00:00
3e66127b94 Write durable outputs through confined file writer 2026-08-09 01:41:37 +00:00
5d086c13ca Cache compiled JSON schemas per validator 2026-08-09 01:38:42 +00:00
d36d4e7689 Avoid redundant decoded graph clones 2026-08-09 01:34:26 +00:00
1456aa51cc Index generated reference handoffs 2026-08-09 01:30:10 +00:00
ffc179c822 Centralize builder request cloning 2026-08-09 01:27:28 +00:00
8e669a1f14 Preserve terminal rejection warnings 2026-08-09 01:19:55 +00:00
14bfae216d Isolate typed validator candidates 2026-08-09 01:11:03 +00:00
5a58d87995 Require candidate decoders for artifact codecs 2026-08-09 01:03:50 +00:00
557809f364 Add candidate artifact codec decoding 2026-08-09 00:57:03 +00:00
ee600975f0 Prevent scheduler callbacks after cancellation 2026-08-09 00:52:31 +00:00
0d8017e23f Gate runner dispatches on cancellation 2026-08-09 00:50:02 +00:00
37b18edf3d Contain provider errors at the LLM adapter 2026-08-09 00:44:38 +00:00
cda7a61b47 Encode checkpoint and debug path identities 2026-08-09 00:41:28 +00:00
2ad9283148 Bound external reference reads and fingerprints 2026-08-09 00:33:33 +00:00
41a8a80dda Reject ambiguous configuration and reference bindings 2026-08-09 00:29:04 +00:00
90c7fa6381 Finalize codebase audit synthesis 2026-08-08 23:00:28 +00:00
e3839f8620 Audit combat and enemy event processing 2026-08-08 22:54:00 +00:00
0fc2f9ee01 Audit spell and scene processing 2026-08-08 22:42:49 +00:00
5d6305f21a Audit NPC item and location occurrences 2026-08-08 22:30:23 +00:00
551e4daea2 Audit NPC item and location registries 2026-08-08 22:18:29 +00:00
3589d33468 Audit shared D&D family conventions 2026-08-08 22:08:24 +00:00
a22c1a7f59 Audit generic and Seriatim modules 2026-08-08 21:59:06 +00:00
ad85d71b0f Audit LLM runtime and prompt assets 2026-08-08 21:47:51 +00:00
f3506240c2 Audit state persistence and file safety 2026-08-08 21:37:57 +00:00
70c199aa31 Audit runtime execution and concurrency 2026-08-08 21:27:37 +00:00
e2b82746ab Audit reference materialization and ordered handoffs 2026-08-08 21:16:30 +00:00
4235507f7b Audit pipeline composition and typed registries 2026-08-08 21:06:28 +00:00
b346670cc7 Audit configuration and CLI composition 2026-08-08 20:56:50 +00:00
7868c26be7 Establish codebase audit baseline 2026-08-08 20:50:15 +00:00
226 changed files with 11310 additions and 5985 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

@@ -22,10 +22,10 @@
"items": {
"type": "object",
"additionalProperties": false,
"required": ["start_segment", "end_segment"],
"required": ["start_unit_id", "end_unit_id"],
"properties": {
"start_segment": {"type": "integer"},
"end_segment": {"type": "integer"}
"start_unit_id": {"type": "integer"},
"end_unit_id": {"type": "integer"}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
Transcript units are the only evidence for extracted events and factual claims.
Every reported factual claim must be supported by cited transcript units. Use
integer `start_unit_id` and `end_unit_id` values from the transcript. Omit
`source_id`; Notarius assigns the current source identity.
integer `start_unit_id` and `end_unit_id` values from the transcript.
When supporting evidence is non-contiguous, use multiple narrow ranges rather
than a broad range that bridges unrelated conversation.

View File

@@ -1,7 +1,5 @@
You process Dungeons & Dragons gameplay transcripts.
Rely only on the supplied inputs. They may contain transcription errors,
repeated lines, incomplete sentences, and misheard proper nouns.
As input, you will receive one or more portions of a transcript. The transcript may contain transcription errors, repeated lines, incomplete sentences, and misheard proper nouns.
Return exactly one JSON object that conforms to the configured response schema,
with no explanatory prose.
Return exactly one JSON object that conforms to the configured response schema, with no explanatory prose.

View File

@@ -1,5 +1,3 @@
One extraction chunk from a Dungeons & Dragons gameplay transcript is provided
below. Report and infer only what is within this chunk. Its unit IDs retain
their source-wide meaning.
One extraction chunk from a Dungeons & Dragons gameplay transcript is provided below. Report and infer only what is within this chunk. Its unit IDs retain their source-wide meaning.
{{ input "transcript" }}

View File

@@ -1,4 +1,3 @@
The complete ordered transcript of this Dungeons & Dragons gameplay session is
provided below. It may contain multiple scenes.
The complete ordered transcript of this Dungeons & Dragons gameplay session is provided below.
{{ input "transcript" }}

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,6 +1,6 @@
The canonical spell-name catalog for this extraction is provided below as JSON.
Return spell names using the catalog's canonical spelling exactly. Aliases and
other campaign reference material are not part of this catalog input and must
not be copied into the output as spell names.
The spell catalog for this extraction is provided below as JSON. Each entry
lists a `canonical_name` and its recognized `aliases`. If the transcript uses
an alias, select that entry's `canonical_name`. Return spell names using the
canonical spelling exactly; never return an alias as a spell name.
{{ input "spell_catalog" }}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
Use only the positive integer `candidate_id` values supplied in the candidate material.
Return a duplicate group only when the evidence supports that every selected candidate describes the same underlying entity. Each group must contain at least two distinct candidate IDs, and its `canonical_candidate_id` must be one of those IDs. A candidate may appear in at most one group.
Omit uncertain matches and candidates that should remain distinct. Do not invent candidates or infer an ID from list position. An empty `duplicate_groups` array is valid.
The response must conform exactly to the selected JSON schema. Return IDs only: do not copy candidate names, evidence, transcript text, source identifiers, or source ranges into the response.

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -12,9 +12,9 @@ an opaque implementation detail. A plain name is likewise insufficient where
multiple supplied records share that name.
The LLM boundary must preserve the typed artifact and durable-schema ownership
of [ADR-0003](0003-strongly-typed-stage-interfaces.md) and the distinction
of [ADR-0003](0003-typed-interfaces-with-two-zone-data-model.md) and the distinction
between disambiguating references and source evidence in
[ADR-0009](0009-prefer-minimal-evidence-grounded-extraction-artifacts.md).
[ADR-0009](0009-minimal-evidence-grounded-extraction-artifacts.md).
## Decision

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
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.
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

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
pipeline-wide artifact rather than a lane entry. Verify its six descriptor
fields before decoding the linked file according to the [Published Evidence
Context contract](../integrations/evidence-context.md). Use each
`evidence_refs` entry as the citation to source material. Its surrounding
context range and included units explain the citation, but do not widen or
replace the cited source reference.
Context contract](../integrations/evidence-context.md). Decode its top-level
source-unit array as a reading excerpt. Obtain authoritative citations and lane
provenance from the normalized lane artifacts; the excerpt has neither and its
nearby units do not widen a lane artifact's cited source reference.
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
@@ -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
retention policy, and avoid copying secrets into arguments, logs, or
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
only when that source content is authorized for the recipient.
metadata and can cover most of an input; preserve and share it only when that
source content is authorized for the recipient.

View File

@@ -67,6 +67,12 @@ including a collision with the embedded catalog. Matching uses the catalogs
case, whitespace, and apostrophe normalization, so authors should avoid names
or aliases that normalize to another spell.
Spell extraction receives the effective catalog as deterministic canonical-name
and alias pairs. An alias in the transcript selects its associated canonical
name; the extractor is instructed to return that canonical spelling. The
projection contains no catalog source metadata or provenance, and aliases
remain recognition context rather than transcript evidence.
The overlay is a recognition aid only. The durable spell-artifact schema and
source-evidence rules are defined by the
[D&D spell artifact contract](dnd-spell-artifacts.md).

View File

@@ -1,9 +1,11 @@
# Published Evidence Context
This contract defines the optional `source/evidence-context` artifact emitted
by the production JSON output. Its configuration is owned by
[Configuration](../config.md#module-bindings-and-validators); its logical-file
discovery is owned by [Published JSON Output](json-output.md).
by the production JSON output. It is a selected source-unit excerpt for
convenient reading alongside normalized lane artifacts; it is not a second
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
@@ -26,91 +28,80 @@ its absence means evidence publication was not enabled for that bundle.
## Payload
The v1 payload is a JSON object with required `source_id`, `source_digest`,
`window_units`, `selected_lanes`, and `contexts` fields. `selected_lanes` and
`contexts` are always arrays; an enabled configuration with no accepted direct
evidence publishes `contexts: []`.
The v1 payload is a top-level JSON array of generic source units. There is no
wrapper, source-level metadata, context grouping, lane identifier, or evidence
reference in the payload. An enabled configuration with no contributing
accepted evidence publishes `[]`.
```json
{
"source_id": "session-alpha",
"source_digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"window_units": 1,
"selected_lanes": ["npc_registry", "spells"],
"contexts": [
{
"context_ref": {
"source_id": "session-alpha",
"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": 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
}
}
]
```
Each context requires a `context_ref` object and `evidence_refs` and `units`
arrays. `context_ref` identifies the first and last included unit. Each
evidence entry contains a selected `lane_id` and an original `source_ref`. A
unit uses the existing source-unit shape: required `id`, `kind`, `text`, and
self `ref`, plus optional JSON-object `metadata`. Fixed payload objects reject
unknown fields; unit metadata may contain application-defined JSON values.
Each source unit has required `id`, `kind`, `text`, and self `ref` fields.
`ref` contains `source_id`, `start_unit_id`, and `end_unit_id`, and both unit
endpoints identify that unit's `id`. A unit may also contain source-owned
`metadata`, an open-ended JSON object. Fixed unit and reference fields are
strict: consumers must reject unknown fixed fields, malformed units, invalid
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
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.
## Selection And Citations
Only accepted outputs from the configured lane allowlist contribute. Rejected,
failed, absent, and lane-filtered outputs do not contribute. The artifact never
contains raw input bytes, prompts, model responses, auxiliary reference
content, credentials, or filesystem paths.
The framework obtains direct source references only through typed evidence
projections of accepted normalized artifacts in the configured lane allowlist.
It validates each reference against the current source document, expands its
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
document position order, not numeric unit-ID order. Direct evidence entries
are deterministically ordered by lane and source reference. Overlapping or
contiguous windows merge, and each source unit appears at most once in the
resulting contexts.
The excerpt contains at most every generic source unit once. It can therefore
equal the complete generic source document when coverage is broad or the
window is large. No byte-, token-, or compression-size guarantee is made, and
the framework does not truncate the excerpt to meet an arbitrary size limit.
## Consumer Responsibilities And Data Handling
The artifact is additive to the JSON bundle and is not a lane payload,
normalized-output count, checkpoint, or generated reference. Consumers that
do not need it must tolerate the absent optional descriptor. Consumers that do
use it should preserve the artifact and its schema identity with the run
provenance, and should treat its source text and metadata as sensitive durable
content.
do not need it must tolerate an absent descriptor. Consumers that do use it
should validate the descriptor and payload before use, retain the artifact with
its schema identity when needed for a run record, and read citations from the
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. |
| `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. |
| `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
accepted only when their media type is `application/json`.

View File

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

View File

@@ -102,7 +102,8 @@ because it changes scheduling rather than execution semantics.
Production construction creates one PromptKit client and wraps it in one
scheduled client. The scheduler has a fixed, positive permit limit, serves
queued calls in FIFO order, and removes a queued call when its context is
cancelled. A granted permit is released exactly once on every completion path.
cancelled. It rechecks the caller context after admission and before dispatch.
A granted permit is released exactly once on every completion path.
The scheduled wrapper surrounds every `CompleteStructured` call, so concurrent
lanes, pipeline retries, and LLM-backed validators share the same provider-call
@@ -141,12 +142,28 @@ arrangement and its data-only boundary are defined by
[ADR-0011](../adr/0011-centralize-llm-assets.md), rather than by this runtime
guide.
The generic registrar is the sole production registration owner for the
semantic-reconciliation default prompt and private response schema. The
domain-neutral reconciliation package also exposes only its mandatory protocol
and candidate/transcript presentation files for domain prompt manifests. D&D
registry normalizers mount those files while retaining ownership and hashing
of their D&D system message, semantic instructions, and complete prompt
declaration. The response schema is therefore registered once even though
several typed normalizers select it.
Mounted prompt assets determine a module's fingerprint. The fingerprint hashes
only the module and shared files explicitly selected by its manifest, so an
unrelated asset does not invalidate a checkpoint. Schema loaders validate JSON,
attach identity and digest metadata, make defensive copies, and expose
diagnostics without raw schema bytes.
Semantic-reconciliation normalizers extend this identity with the shared
response-schema digest, framework policy version, and complete limit-policy
digest. Their manifest metadata records the same content-free prompt, schema,
policy, and limit identities together with domain identity and normalization
policies. Request-local handles, source material, proposal content, and raw
asset bytes are not checkpoint metadata.
Private response schemas validate a model transport envelope. They are not the
durable artifact schema and should not be documented as an external wire
contract. Durable formats and compatibility rules remain in the
@@ -179,7 +196,9 @@ structured-output validation. The adapter reports an empty result, validation
failure, empty structured body, or decode failure as
`ErrInvalidStructuredOutput`, while retaining the returned raw bytes and debug
material when they exist. Provider failures remain operational errors rather
than output-validation failures.
than output-validation failures. Apart from documented context, capacity, and
invalid-output categories, provider error values and types do not cross the
adapter error chain; callers receive only a credential-redacted diagnostic.
When PromptKit rejects backend admission before generation, the adapter maps
`promptkit.ErrCapacityExceeded` to

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

View File

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

View File

@@ -46,16 +46,18 @@ External reference materialization happens before preparation. The materializer
checks that each slot is declared by the selected module, resolves a file path
relative to the correct configuration or working-directory origin, reads
UTF-8 text, verifies media type and size limits, and retains bounded
provenance. A generated-artifact selector remains declared but has no bytes
until its producing step completes.
provenance. For a positive slot limit, it reads at most the limit plus one byte
and rejects overflow before retaining content. A generated-artifact selector
remains declared but has no bytes until its producing step completes.
Preparation is the construction boundary. It validates the resolved shape and
registry set, clones the resolved data, then constructs the input adapter,
chunker, stage-local validators, every typed lane, and output encoder with
cloned options, references, and shared dependencies. It also collects stable
checkpoint fingerprints. Missing registrations, incompatible typed entries,
nil implementations, and constructor failures are reported before source
parsing or any stage operation begins.
chunker, stage-local validators, every typed lane, and output encoder. Each
registered builder receives its own cloned build request immediately before its
module-owned code runs. Preparation also collects stable checkpoint
fingerprints. Missing registrations, incompatible typed entries, nil
implementations, and constructor failures are reported before source parsing
or any stage operation begins.
An output encoder can opt into source-evidence publication through its output
policy. Preparation keeps the configured lane allowlist and active lanes
@@ -115,9 +117,12 @@ for started workers, and prevents output encoding.
Every chunk, extract, merge, and normalize candidate passes its resolved
validator chain. Validators receive immutable canonical input appropriate to
their target: chunks, typed values, or serialized codec bytes. They may
approve, approve with warnings, reject, or fail. A rejection is an ordinary
pipeline result; a validator error is a framework error.
their target: chunks, codec-decoded typed candidates, or serialized codec
bytes. Each typed validator receives a newly decoded value from the one
candidate serialization for that attempt, while serialized validators receive
separately owned representation bytes and schema metadata. They may approve,
approve with warnings, reject, or fail. A rejection is an ordinary pipeline
result; a validator error is a framework error.
The runner applies the binding's retry policy around a stage operation and its
complete validation chain. It preserves warnings only from the final accepted

View File

@@ -47,6 +47,9 @@ The serialized
they do not describe a current public state surface.
Ordered-step lane checkpoints include the step identity in their storage scope.
Accepted step and lane identities are encoded injectively before becoming
filesystem path components, while ordinary safe identifiers retain their
readable paths.
When a later lane consumes a generated artifact, its dependency fingerprints
include the producer's artifact kind, complete schema identity, media type,
canonical content digest, and size. Ordinary resume compares those fingerprints

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
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
controls to the entire bundle. Selected lanes may collectively cite most of a
transcript, so a broad allowlist can make the evidence artifact nearly as
sensitive and large as the source itself.
controls to the entire bundle. Its selected source-unit excerpt may include
every source unit once when coverage is broad or its configured window is
large, so do not assume a byte or token reduction or reduced sensitivity.
## 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
extractor.
A stage module is one configured implementation of one pipeline stage. An
artifact family is the cohesive domain feature that owns an artifact across
the explicit stages and supporting codecs, validators, prompts, identity
rules, and reference projections. Artifact-family ownership does not combine
stages or alter the fixed pipeline.
Input and chunking are pipeline-wide. Each selected artifact lane owns its
extract, merge, and normalize stages, and the output stage aggregates the run's
lane outcomes.
@@ -39,6 +45,12 @@ implementations. Domain-neutral model and framework layers provide reusable
policy, contracts, and orchestration. Concrete input, pipeline, output, and
validation extensions depend inward on those generic layers.
Semantic reconciliation is one such domain-neutral framework mechanism. It
prepares bounded source context, invokes a shared model-judgment protocol,
validates proposals, and applies safe plans through typed policies supplied by
the consuming artifact family. It does not own domain identity, durable IDs,
warning semantics, or artifact construction rules.
Generic layers must not depend on production extensions. Concrete extensions
must not compose the application or take ownership of process behavior. The
current packages implementing these layers are inventoried in
@@ -186,8 +198,13 @@ or domain-specific prompt logic.
When a model selects an application entity, callers must supply a contextual
selection and deterministically attach the opaque application identity whenever
the selection resolves exactly. Models do not receive or reproduce opaque
application identifiers; [ADR-0012](../adr/0012-resolve-opaque-entity-identifiers-deterministically.md)
records the rationale and limited request-local-label exception.
application identifiers. Semantic reconciliation may instead expose
contiguous, one-based candidate handles that exist only for one request;
deterministic code resolves them before typed application, and they never
become durable identity. This is the approved request-local-label application
of [ADR-0012](../adr/0012-resolve-opaque-entity-identifiers-deterministically.md)
recorded by
[ADR-0013](../adr/0013-use-request-local-candidate-handles-for-semantic-reconciliation.md).
LLM calls and other external operations accept cancellation and respect
timeouts. Concurrency control belongs in shared runtime plumbing rather than in

File diff suppressed because it is too large Load Diff

View File

@@ -1,516 +0,0 @@
# Codebase Audit Plan
## Purpose
This document defines a repository-wide audit of Notarius for correctness,
efficiency, maintainability, and clarity. The audit should identify concrete
improvements without treating abstraction, fewer lines, or higher test coverage
as goals in themselves.
The audit is intentionally separate from implementation. Its findings should
be evidence-backed and sufficiently specific to support a later remediation
roadmap, but the audit should not modify production code, tests, assets, or
current-behavior documentation.
## Governing Principles
The audit must preserve the architecture and testing policies in
`docs/policy/architecture.md` and `docs/policy/testing.md`.
In particular:
- Notarius remains a fixed, staged pipeline rather than a general workflow
engine.
- Generic framework packages must remain domain-neutral, and production
modules must not acquire CLI or physical-state responsibilities.
- Typed artifact boundaries, exact codec compatibility, deterministic ordering,
whole-output validation, and generated-reference provenance are correctness
properties, not incidental complexity to be optimized away.
- The root `assets` package remains a content-only dependency leaf.
- Shared helpers should protect demonstrated common semantics. Similar-looking
code with different ownership, error policy, identity rules, or type contracts
should remain separate.
- Tests should protect durable behavior and meaningful risks. The audit should
not recommend tests merely to increase coverage or freeze implementation
details.
- Efficiency claims must distinguish measured or structurally credible costs
from cosmetic line-count reductions. Optimizing local CPU work that is
insignificant beside an LLM call is low priority unless it also simplifies
correctness or applies to large inputs.
## Audit Questions
Every audited area should be examined through the following questions.
### Correctness
- Are documented architecture invariants enforced at the correct boundary?
- Can invalid configuration, incompatible artifact types, malformed references,
or unavailable dependencies reach execution when they could be rejected
during resolution or preparation?
- Are nil, empty, absent, rejected, failed, and canceled states distinguished
consistently?
- Are stored or returned slices, maps, byte slices, options, metadata, source
documents, references, and artifacts defensively owned where required?
- Are public ordering, selected errors, warnings, and checkpoint decisions
deterministic regardless of map or goroutine completion order?
- Do cancellation, retry, validation, and partial-work semantics match their
documented ownership?
- Do checkpoint and chunk-plan identities include every semantic dependency and
exclude scheduling-only or diagnostic state?
- Can auxiliary references accidentally become source evidence, or can
generated references bypass codec, schema, provenance, or step-order checks?
- Can provider-specific values, credentials, or source content escape through
errors, manifests, debug summaries, cache state, or logs?
- Do schemas, codecs, candidate decoders, normalizers, and validators agree on
the exact durable contract without silently accepting incompatible shapes?
### Duplication And Shared Mechanics
- Which exact or near-duplicate implementations express the same invariant and
failure policy?
- Has duplicated code already drifted in naming, nil handling, canonicalization,
metadata, fingerprints, validation, or diagnostics?
- Would a helper have a natural owner and a smaller, clearer contract than the
duplicated callers?
- Can an extraction preserve static typing and package ownership, or would it
require reflection, `any`, callbacks with many policy parameters, or a
domain-neutral package importing domain concepts?
- Is repeated code required by a small interface adapter or typed registration
boundary and therefore clearer when left explicit?
As a default heuristic, prioritize a shared helper when identical semantics
appear in three or more production sites, or in two sites where divergence
would create a meaningful correctness risk. Do not use that heuristic as a
quota: one substantial duplicate may warrant extraction, while widespread
one-line interface methods may not.
### Simplicity And Idiomatic Go
- Does a function combine orchestration, policy, transformation, persistence,
and reporting that could be separated along existing ownership boundaries?
- Are repeated scans, sorts, conversions, clones, encodes, or decodes doing work
that can safely occur once?
- Are intermediate representations necessary, or can a value be validated,
canonicalized, and mapped in one comprehensible pass?
- Are maps, sets, stable sorts, generics, standard-library helpers, and error
wrapping used idiomatically?
- Are abstractions earning their complexity, or are interfaces, option layers,
wrappers, aliases, compatibility paths, and private types left over after a
completed migration?
- Are there unreachable error branches, redundant fingerprints or digests,
duplicated sources of truth, or accessors used only by tests?
- Can a smaller implementation preserve exact observable behavior and safety
properties?
### Explanatory Comments
Comments should be recommended where the code is necessarily complex because
it preserves a non-obvious invariant. Good candidates include:
- concurrency coordination, cancellation, and stable error selection;
- checkpoint identity, reuse, forced recomputation, and dependency invalidation;
- typed erasure and restoration at framework boundaries;
- generated-reference ordering and provenance;
- canonicalization and identity resolution where registry evidence differs
from occurrence evidence;
- prompt ordering or input identity required for backend caching; and
- path confinement, atomic publication, redaction, or terminal error precedence.
Recommend comments that explain *why* a step or ordering constraint exists and
what would break if it changed. Do not recommend comments that narrate syntax,
repeat a function name, duplicate current-behavior documentation, or preserve
implementation history.
### Tests
- Is each consequential invariant protected at the narrowest stable boundary?
- Are concurrency, cancellation, retries, recovery, compatibility, path safety,
and data-integrity behavior credibly exercised?
- Do higher-level contract tests duplicate lower-level cases without adding
integration confidence?
- Are tests coupled to private constants, helper shape, exact prose, full error
strings, or collaborator choreography rather than behavior?
- Can repetitive fixtures or fakes be simplified without creating a test
framework more complex than the tests?
- Would a focused fuzz test, race test, or package-level invariant test protect
a realistic risk better than several example tests?
## Evidence And Finding Standards
Static metrics and textual similarity are discovery aids, not findings. A long
function may be a clear linear coordinator; identical methods may be useful
typed adapters. Every reported finding must include:
1. a concise title and severity;
2. exact files and symbols;
3. the observed behavior or structural evidence;
4. the correctness, efficiency, maintenance, or comprehension impact;
5. a concrete recommended direction;
6. important invariants the remediation must preserve;
7. focused validation that would demonstrate success; and
8. whether the recommendation is independent or should be grouped with another
finding.
Use these severities:
- **High:** a credible risk of corrupt output, unsafe state handling, secret
exposure, stale reuse, deadlock, nondeterminism, or violated external
contract.
- **Medium:** a plausible behavioral defect, meaningful wasted work on common
paths, or complexity/duplication likely to cause future correctness drift.
- **Low:** a contained simplification, small efficiency improvement, dead code,
naming issue, or missing explanation with no current behavioral failure.
The audit should explicitly record examined areas with no findings. This makes
coverage visible and prevents later agents from repeatedly rediscovering the
same safe design.
## Repository Areas
### 1. Architecture And Dependency Boundaries
Inspect `docs/policy/architecture.md`, `docs/adr/`, `docs/internal/overview.md`,
package imports, module registrars, and the CLI composition root.
Look for:
- framework or core code depending on production modules;
- modules depending on CLI, physical roots, or provider-specific types;
- domain knowledge placed in generic helpers;
- duplicated registries or composition policy outside the owning registrar;
- abstractions that turn the fixed pipeline into an implicit general graph; and
- current code that no longer matches an accepted ADR or documented invariant.
Graph-reported cross-layer calls must be traced before being classified because
tests and interface implementations can resemble dependency inversions without
creating a production import violation.
### 2. Configuration And CLI Composition
Inspect `internal/core/config`, `internal/cli`, configuration parsing and
redaction tests, profile construction, session derivation, catalog assembly,
reference overrides, run-result handling, terminal reporting, and maintained
example contract tests.
Pay particular attention to the currently dense paths around
`runPipelineCommand`, configuration profile validation, selected reference
targets, recomputation policy, and option normalization. Determine whether
their complexity reflects necessary composition or mixed responsibilities that
can be separated without moving policy into the framework.
Verify:
- file, environment, CLI, pipeline, binding, and prompt-default precedence;
- consistent strict option and unknown-field handling;
- session identity independence from references and pipeline-local changes;
- effective profile and runtime fingerprint consistency;
- redaction before errors or debug/manifest boundaries;
- output publication only after framework success; and
- one guarded terminalization path that preserves the primary failure.
### 3. Pipeline Resolution, Preparation, And Typed Registries
Inspect `internal/framework/pipeline/profile.go`, `prepare.go`, registry files,
`options.go`, `references.go`, `handoff.go`, `construction.go`, typed contracts,
and their focused tests.
This area deserves a dedicated pass because the current graph identifies
`ResolvePipeline`, generated-binding validation, reference-target resolution,
and generated-reference construction as high-complexity or high-fan-in code.
Verify:
- static failures occur before source parsing;
- selected and unselected lanes do not contaminate each other's requirements;
- stage defaults and overrides have one canonical resolution path;
- typed registration and private erasure cannot panic or accept near-matching
artifact types;
- generated bindings reject cycles, forward references, ambiguity, wrong kinds,
and missing accepted normalized producers;
- materialized reference bytes and options are cloned and bounded; and
- resolved composition and prepared fingerprints include the complete semantic
policy exactly once.
Compare input, chunker, extractor, merger, normalizer, output, validator, codec,
evidence-projector, and validator-chain registries for shared mechanics and
intentional differences. Repeated typed registration code is a candidate only
if a helper can retain useful compile-time guarantees and stage-specific
diagnostics.
### 4. Pipeline Execution, Validation, Retry, And Concurrency
Inspect `runner*.go`, `typed_execution.go`, `runner_typed.go`,
`runner_concurrent.go`, validation-chain execution, normalize retry behavior,
synchronized collaborators, and the concurrency, cancellation, retry, debug,
and checkpoint tests.
Trace complete paths rather than reviewing helper files in isolation:
- source and chunk-plan selection through chunk validation;
- deterministic chunk-first/lane-second dispatch;
- lane extraction through merge and normalize continuations;
- rejection versus framework-error propagation;
- cancellation before dispatch, while queued, and while running;
- retry attempts and warning retention;
- stable error selection after concurrent completion;
- checkpoint hydration back into typed execution; and
- output suppression after a framework error.
Look for goroutine leaks, unbounded work, lock-order risks, double release or
double recording, races on shared result state, unnecessary serialization,
and repeated canonicalization. Comments are especially valuable here when they
explain ordering or cancellation invariants that are not apparent from local
control flow.
### 5. State, Checkpoints, Chunk Plans, Debugging, And File Safety
Inspect `internal/framework/checkpoint`, `chunkplan`, `chunkmap`, `debug`,
`evidencecontext`, `internal/core/fileio`, `debugbundle`, and their CLI
composition.
Verify:
- narrow path validation and symlink-resistant confinement;
- atomic writes and recoverable explicit cleanup;
- separation of checkpoint recording, resume loading, chunk-plan caching, and
debug capture;
- canonical encoding before content identity is trusted;
- complete but non-secret checkpoint fingerprints;
- correct ordinary-resume and selective-recompute behavior;
- producer dependency invalidation across ordered steps;
- immutable hydration and no aliasing with stored bytes;
- debug data never influencing execution or reuse; and
- terminal persistence failures never obscuring the primary error.
Review the repeated extract/merge/normalize recorder and loader methods, path
component validators in multiple state packages, and clone/encode/decode paths.
Determine which repetition is a clear stage adapter and which can share a
private primitive without weakening reason-code ownership or diagnostics.
### 6. LLM Runtime, Prompt Filesystems, And Assets
Inspect `internal/framework/llm`, `promptfs`, the PromptKit integration,
scheduler, profile-source construction, prompt/schema registries, root
`assets`, module prompt manifests, and relevant D&D shared assets.
Verify:
- every provider call passes through the shared scheduler and cancellation
removes queued calls safely;
- PromptKit and Notarius concurrency limits compose as documented;
- profile inspection and runtime use identical source precedence;
- session IDs, profile-source fingerprints, prompt fingerprints, and schema
fingerprints reflect the intended semantic inputs;
- secrets and provider-specific error types do not cross the boundary;
- prompt inputs and private outputs do not expose opaque entity IDs;
- prompt ordering, stable prefixes, and cache controls remain intentional;
- schema loaders and filesystem adapters validate once and return defensive
data; and
- the root assets package contains no business logic.
Compare the LLM asset registry and prompt-filesystem adapters for duplicated
filesystem behavior. Review repeated prompt/schema loader and metadata code in
module packages, but reject an extraction that would centralize domain prompt
ownership or make unrelated assets share one invalidation boundary.
### 7. Generic And Seriatim Modules
Inspect `internal/modules/generic` and `internal/modules/seriatim`, including
module specs, option decoding, chunk planning, input translation, validators,
output encoding, evidence-context publication, registration, and tests.
Verify that:
- external Seriatim details end at the input boundary;
- generic chunking and output remain domain-neutral;
- chunk plans and source units preserve source-addressed invariants;
- output logical names are safe and deterministic;
- output options do not bypass preparation-time compatibility checks; and
- option decoding is strict, small, and consistent with configuration
validation.
The graph flags generic integer option parsing and JSON output policy decoding
as relatively complex. Examine whether that is inherent strict decoding or an
opportunity for a smaller typed parser with equally precise diagnostics.
### 8. D&D Domain Model, Codecs, And Shared Helpers
Inspect `internal/modules/dnd` domain types, codecs, candidate decoders,
identity packages, registries, shared source-reference helpers, diagnostics,
registry resolution, entity reconciliation, mergers, registrar, and assets.
Compare all ten current artifact families. Build a convention matrix covering:
- module specs and execution classes;
- constructor and option behavior;
- manifest metadata and checkpoint fingerprints;
- response-schema loading and private-versus-durable types;
- source-reference conversion, canonicalization, ordering, and deduplication;
- nil versus present-empty output;
- codecs and strict JSON behavior;
- registry lookup, identity derivation, immutable projections, and resolution;
- normalizer retry/fallback behavior;
- validators and default chains; and
- registration, prompt assets, and documentation ownership.
The graph reports many exact similarities among codec `Decode` methods,
fingerprint/metadata methods, registry extractors, identity helpers, occurrence
normalizers, and validators. Treat these as a prioritized review list, not an
instruction to create one generic D&D engine. A worthwhile helper must preserve
domain-specific identity, evidence, kind ordering, validation, diagnostics,
and artifact typing.
### 9. D&D Extraction And Normalization Flows
Trace each lane end to end rather than auditing only similarly named files:
- spells;
- NPC registry and NPC occurrences;
- combat turns and enemy events;
- item registry and item occurrences;
- scene descriptions; and
- location registry and location occurrences.
For registry/occurrence pairs, verify the complete semantic boundary: the model
uses contextual evidence, deterministic code attaches opaque identity, registry
evidence does not become occurrence evidence, and unresolved or ambiguous
selections fail according to lane policy.
Review whether any lane resolves or canonicalizes the same entity, source
reference, or response twice; constructs unnecessary intermediate response
forms; performs repeated sorts or scans; or retains transitional paths. Compare
registry normalizers and occurrence normalizers for genuinely identical
mechanics, while keeping currency, same-name location, NPC ambiguity, spell
catalog, scene eligibility, and combat-specific policy with their owners.
### 10. Test Suite And Comment Coverage
Review the tests associated with every preceding area after understanding the
production contracts. This should be a cross-cutting pass, not a request to add
tests for every flagged function.
Identify:
- consequential unprotected invariants;
- duplicated policy assertions across layers;
- brittle tests coupled to internal constants, prompt prose, or private helper
shape;
- oversized test harnesses and repeated fixtures that obscure intent;
- race-sensitive code not exercised under `-race`;
- parsers, canonicalizers, and path handlers where fuzzing would address a real
input-space risk; and
- complex production code whose tests reveal an unclear ownership boundary.
Also identify necessarily complex symbols that lack a concise invariant-level
comment. Comment recommendations should name the exact symbol and the fact the
comment should explain; “add more comments” is not an actionable finding.
## Efficiency Evaluation
The audit should consider both runtime and maintenance efficiency.
For runtime efficiency, examine algorithmic behavior relative to realistic
input dimensions: source units, chunks, lanes, references, artifacts, registry
records, checkpoint files, and prompt assets. Prioritize repeated full-input
passes, nested linear lookup, unnecessary JSON round trips, repeated hashing,
large defensive copies at adjacent ownership boundaries, and serialization on
concurrent hot paths. Preserve a defensive copy when it establishes ownership;
removing it solely to reduce allocation is not an improvement.
For maintenance efficiency, prioritize repeated policy, parallel type systems,
duplicated error classification, scattered defaults, and migrations that left
two ways to perform the same operation. Boilerplate is costly only when it can
drift or obscures the semantic core. Small explicit typed adapters can be more
maintainable than a generic abstraction.
Do not recommend caching, pooling, concurrency, or a benchmark without naming
the workload and risk it addresses. Add a benchmark only when a proposed
optimization concerns a repeatable local path and the result would influence
the decision.
## Audit Method
Each area should use the same method:
1. Read its architecture/internal documentation and focused tests.
2. Map public/package contracts and trace the main call paths.
3. Inspect high-fan-in, high-cognitive-complexity, nested-loop, and repeated-
conversion symbols.
4. Review exact and near-duplicate code side by side, including callers and
failure semantics.
5. Check dependency direction, ownership, aliasing, deterministic order,
cancellation, and error classification.
6. Compare tests with the risks owned at that layer.
7. Record findings and inspected-with-no-finding areas before moving on.
8. Run focused read-only validation when it can confirm or refute a suspected
problem.
Prefer the repository knowledge graph for symbol discovery, call tracing, and
similarity candidates. Use textual search for literals, diagnostics, config
keys, asset content, and stale names. Read complete implementations and tests
before reporting a metric-derived candidate.
## Execution Sequence
This document owns audit scope, questions, evidence standards, and the quality
bar. [Staged Codebase Audit Sequence](audit-sequence.md) is the sole canonical
owner of prompt order, stage boundaries, per-stage reading, validation commands,
and acceptance criteria. Do not derive or maintain a second sequence here.
The audit is executed as bounded prompts and writes its accumulated findings to
`docs/roadmap/audit.md`. Later stages must build on and reconcile earlier
evidence rather than concatenate independent reports. Implementation and
roadmap retirement remain separate work after maintainers review the completed
audit.
## Baseline And Validation
Before the first audit stage, record the commit under review and require a clean
worktree. Refresh the code knowledge graph so renamed or deleted code does not
produce false findings. Run the normal offline baseline:
```sh
go test ./...
go vet ./...
go build ./cmd/notarius
git diff --check
```
Run `go test -race` for packages with concurrency or mutable shared state,
especially `internal/framework/pipeline`, `internal/framework/llm`, state
packages, and D&D registries. A repository-wide race run is appropriate for
final verification if its cost remains reasonable.
Optional diagnostic commands should be used only when relevant:
- `go test -count=1` to rule out cache-masked failures;
- `go test -shuffle=on` to detect order coupling;
- focused fuzzing for existing or newly justified fuzz targets; and
- focused benchmarks or profiles for a specific efficiency finding.
The audit itself should not change tests to make the baseline pass. Record any
pre-existing failure and distinguish it from an audit finding.
## Deliverable Quality Bar
The completed audit should:
- cover every repository area listed above;
- distinguish defects from refactoring opportunities and comment requests;
- distinguish credible performance costs from aesthetic simplification;
- identify intentional duplication that should remain explicit;
- avoid recommendations that violate dependency direction or weaken typing;
- cite exact evidence and preserve named invariants for every finding;
- consolidate root causes rather than report many symptoms;
- rank independent work so a later implementation plan can stage it safely;
- recommend no code change whose expected benefit is smaller than its added
abstraction or test-maintenance cost; and
- leave implementation and roadmap retirement to later work.
## Open Questions
None are required to begin the audit. If a later stage cannot determine whether
behavior is intentional from code, tests, policies, ADRs, or current
documentation, it should record the uncertainty and a recommended resolution
rather than silently treating preference as a defect.

File diff suppressed because it is too large Load Diff

View File

@@ -1,350 +0,0 @@
# Contextual Entity Grounding
## Purpose
Notarius should use an LLM for semantic interpretation of source evidence, not
for referential-integrity work that deterministic code can perform more
reliably. D&D prompts must therefore stop requiring models to reproduce opaque
machine identifiers such as hash-derived entity IDs. Models should identify
entities through human-readable, evidence-grounded context, after which
Notarius resolves the selection and attaches the canonical durable identity.
This roadmap defines the policy, affected D&D prompt families, and intended
end state. The ordered work needed to reach that state is maintained in
[Implementation Plan](implementation.md).
## User Intent
The change has two goals:
- prevent otherwise useful model responses from failing because a long,
non-semantic string was copied incorrectly; and
- avoid spending prompt space and model effort on exact-copy work that provides
no semantic value.
The policy is not a ban on identifiers. Durable artifacts may continue to use
application-owned IDs, and prompts may continue to request source-unit ranges
that locate evidence. The policy governs which identity work is assigned to
the model.
## Policy
An LLM-facing prompt input or private response schema must not require a model
to reproduce an opaque machine identifier when Notarius can establish the same
association deterministically.
Opaque machine identifiers include cryptographic hashes, UUIDs, digests,
database keys, durable entity IDs, and other tokens whose characters do not
carry source-grounded meaning for the model. These values may remain in
application state, provenance, diagnostics, checkpoints, and durable artifact
contracts, but should be omitted from model-visible material when they do not
help the model make a semantic decision.
The intended responsibility boundary is:
- the model decides which contextual entity is supported by the supplied
evidence and returns the bounded semantic facts requested by the module;
- the calling module validates that the contextual selection resolves to
exactly one supplied candidate;
- deterministic code supplies the canonical display value and durable entity
ID; and
- existing validators continue to enforce referential integrity at later
artifact boundaries.
Transcript `start_unit_id` and `end_unit_id` values are permitted. They are
contextual source coordinates and form part of the evidence contract rather
than arbitrary identity tokens. Prompt IDs, schema IDs, fingerprints, session
IDs, and digests may also remain in runtime metadata that the model is not
asked to reproduce.
Short request-local labels are a narrowly permitted fallback only when a
contextual selector cannot uniquely represent the available choices without
unreasonable prompt cost. Such a label must be compact, scoped to one request,
validated against the supplied candidate set, and never reused as a durable
identity. Current D&D occurrence and reconciliation prompts should be designed
without this exception; adopting it later requires a concrete demonstrated
need and documented rationale.
## Current State
The initial NPC, item, and location registry extractors already follow the
desired pattern: the model returns contextual names and evidence, and Notarius
derives durable IDs afterward. Spells, combat turns, and enemy events use
contextual actor names rather than requiring hash-derived NPC IDs.
Two current prompt families diverge from that pattern:
1. `dnd/npc-occurrences`, `dnd/item-occurrences`, and
`dnd/location-occurrences` place durable registry IDs in model-visible
projections and require the private LLM response to repeat those IDs.
2. NPC-, item-, and location-registry normalization use the shared entity
reconciliation prompt, which labels candidates with opaque
`candidate-000001`-style keys and requires the model to copy those keys into
duplicate-group proposals.
The durable occurrence artifacts correctly retain canonical entity IDs. The
problem is the private model transport contract, not the published artifact
contract.
## Target Architecture
### Model proposals and durable artifacts
Private LLM response types must express contextual semantic proposals rather
than reuse the durable artifact type when that type contains an opaque entity
ID. The extractor maps a validated private response into the existing durable
artifact only after identity resolution succeeds.
No affected durable artifact kind, media type, schema ID, schema version, or
JSON field changes as part of this work. NPC, item, and location occurrence
artifacts continue to publish their exact canonical ID/name pair. Registry
artifacts likewise retain their IDs and evidence.
The private schemas and prompt declarations may remain at their current `v1`
identities because Notarius is pre-release and these are not external
contracts. Their content hashes, mapping-policy fingerprints, and affected
prompt fingerprints must change so incompatible checkpoints are not reused.
### NPC occurrence grounding
The NPC occurrence prompt receives an ordered names-only projection of the
normalized NPC registry. Its private response contains the canonical NPC name,
occurrence kind, and current-transcript source ranges, but no `npc_id`.
The extractor resolves the returned name under the existing NPC comparison
policy. Resolution must produce exactly one registry entry. It then writes that
entry's canonical display name and durable ID into the `dnd.NPCOccurrence`.
An unknown or ambiguous selection invalidates the extraction operation; the
extractor must not guess, use fuzzy matching, silently omit the record, or
accept a partial response.
### Item occurrence grounding
The item occurrence prompt receives an ordered names-only projection of the
normalized item registry. Its private response contains the canonical item
name, occurrence kind, kind-specific fields, and current-transcript source
ranges, but no `item_id`.
The extractor resolves the returned name under the existing item comparison
and identity policies. Resolution must produce exactly one registry entry,
whose canonical name and durable ID are attached deterministically. Unknown or
ambiguous selections invalidate the complete extraction operation rather than
being guessed, repaired by similarity, or dropped.
### Location occurrence grounding
Location identity cannot always be resolved from a display name alone: the
current registry intentionally permits same-name locations with distinct
source anchors. The location occurrence prompt must therefore receive a
contextual registry descriptor that contains the canonical display name plus
the minimum source-grounded registry evidence needed to distinguish same-name
records. It must not contain the durable `location:sha256:...` value.
The private response uses two required selector fields: `name` and
`registry_refs`. For a comparison-unique canonical name, `registry_refs` is an
empty array and Notarius resolves the name under the location comparison
policy. For a name shared by multiple registry records, `registry_refs`
contains that record's complete canonically ordered registry ranges as
`start_unit_id` and `end_unit_id` pairs, without `source_id`.
Every model-facing registry entry uses one fixed shape with required `name`,
`registry_refs`, and `context` fields. `context` is an array of strict objects
containing only `unit_id` and `text`. Comparison-unique entries use empty
`registry_refs` and `context` arrays. Same-name entries use the complete
registry-range selector and the bounded context described below. The model
returns only `name` and `registry_refs`; it does not reproduce `context`.
For same-name groups, the projection also supplies bounded transcript units
covered by each record's registry ranges so the model receives meaningful
identity context rather than coordinates alone. Those ranges must resolve
against the current source document, and the resulting contextual selectors
must be unique. An invalid range or selector collision prevents the LLM call
and fails the operation. Unique-name entries do not repeat registry ranges or
context in the selector, preserving compatibility with a valid registry from
another source when the name alone is unambiguous.
The private response separately supplies current-transcript `source_refs` that
prove the occurrence. Registry identity evidence and occurrence evidence must
remain different fields and must never be merged. The model should omit an
occurrence when the transcript does not support choosing among same-name
locations. If a returned selector does not resolve to exactly one supplied
registry record, the extractor invalidates the complete operation rather than
guessing.
### Registry reconciliation
The shared entity-reconciliation input replaces opaque candidate keys with
contextual candidate descriptors. At minimum, a descriptor contains the
candidate's display name and its canonically ordered source-reference ranges;
the existing transcript windows remain available for semantic judgment.
Duplicate-group members and the canonical member in the private response use
the same contextual descriptor shape. The shared reconciliation helper maps
each descriptor back to exactly one internal candidate before assessing the
proposal. Exact deterministic duplicates should already be removed before the
LLM call; any remaining descriptor collision makes the affected candidate
ineligible for model-assisted reconciliation rather than authorizing an
arbitrary choice.
Existing safety behavior remains in force: groups must contain at least two
supplied candidates, the canonical candidate must be a member, groups must not
overlap, and domain-specific eligibility rules remain authoritative. Invalid,
ambiguous, or unsafe groups are discarded through the existing bounded
fallback and diagnostic behavior. The model never directly mutates the
durable registry.
The shared private reconciliation schema and helper must remain domain-neutral
within the D&D family. NPC-, item-, and location-specific duplicate policy
continues to live in the owning normalizer.
## Prompt And Asset Changes
The following LLM-facing assets are in scope:
- the prompt instructions, registry input fragments, and private response
schemas for NPC, item, and location occurrences;
- the prompt manifests where input shape or selected fragments change;
- the shared D&D entity-reconciliation fragment and private response schema;
and
- the NPC-, item-, and location-registry normalization prompt inputs that use
the shared reconciliation contract.
Affected projections must exclude durable entity IDs rather than merely stop
mentioning them in prose. Prompt instructions should describe the contextual
selection rule once at the narrowest owning asset and must preserve the current
distinction between registry grounding and transcript evidence.
Prompt ordering and cache controls should remain unchanged unless the new
contextual input requires an intentional manifest change. Unrelated shared
prompt bytes should not be edited. Prompt and schema fingerprints should
invalidate only the operations whose selected assets or mapping semantics
changed.
## Code And Validation Changes
The occurrence extractors need private response types and deterministic
registry-resolution paths appropriate to their domain. Shared code is
appropriate only for demonstrated mechanics that have identical semantics;
NPC, item, and location ambiguity policies must not be forced behind a generic
resolver merely to reduce line count.
Registry projections should expose explicit model-facing methods whose names
describe whether they are names-only or contextual identity projections. The
existing ID/name projections may remain only for deterministic consumers that
genuinely require them; they must no longer be wired to an LLM input.
Mapping-policy and normalization-policy identifiers must be reviewed and
advanced wherever their semantics change. Checkpoint fingerprints must cover
the new projection content, private schema, prompt assets, and mapping policy,
while continuing to exclude irrelevant internal implementation details.
Durable occurrence normalizers and registry validators remain defense in
depth. They continue to validate exact ID/name pairs on artifacts entering
through checkpoints, codecs, or other boundaries even though the LLM no longer
produces the ID directly.
## Testing And Evaluation
Tests should protect the behavioral boundary rather than prompt prose or
private helper structure. The completed work should demonstrate that:
- affected model-facing registry projections do not contain durable entity
IDs;
- private occurrence schemas reject opaque ID fields and accept the intended
contextual shape;
- valid contextual selections map to the exact canonical durable ID/name pair;
- unknown, mismatched, and ambiguous selections fail without fuzzy matching,
partial acceptance, or arbitrary reassignment;
- same-name locations remain distinguishable through contextual evidence;
- reconciliation preserves equal-name candidates, resolves valid contextual
groups, and discards ambiguous or unsafe proposals;
- registry evidence never becomes occurrence evidence;
- durable codec, normalization, and validator behavior remains compatible; and
- representative assembled D&D pipelines still prepare and execute with fake
structured-LLM responses.
Do not add repository-wide prompt-prose snapshots, exact-message-count tests,
or a change-detector test that merely scans for today's field names. Focused
projection, schema, mapping, fallback, and integration tests are the stable
owners of these risks. Model-quality evaluation with representative
transcripts remains a manual development aid rather than an offline test gate.
## Documentation And Architectural Record
This policy is durable and applies to future modules, so it warrants
`docs/adr/0012-resolve-opaque-entity-identifiers-deterministically.md`, which
records:
- the semantic-proposal versus referential-integrity boundary;
- why durable opaque IDs are excluded from model response contracts;
- why contextual evidence coordinates remain permitted;
- the narrowly scoped request-local-label exception;
- alternatives including durable IDs, names-only matching, and short opaque
handles; and
- the consequences for private schemas, deterministic resolution, debugging,
and ambiguous identities.
`docs/policy/architecture.md` states the general LLM boundary invariant and
links to the ADR. `docs/internal/dnd.md` describes the concrete occurrence
projections, contextual reconciliation selectors, resolution and failure
behavior, and the continued separation of registry grounding from occurrence
evidence. `docs/internal/llm.md` contains only a short clarification that
caller-owned modules, not PromptKit or the transport adapter, resolve
contextual model selections into application identities.
The NPC, item, and location occurrence and registry integration documents must
continue to own their durable wire contracts, while removing current claims
that the model-facing consumer projection contains `{id,name}` or that the raw
LLM response supplies the durable ID. They should instead explain that
Notarius resolves contextual model output and publishes the same exact durable
ID/name pair. No public schema examples need to remove those IDs.
The generic LLM-assisted deduplication entry in `docs/roadmap/future.md` must be
reconciled with this policy: stable IDs may exist inside deterministic state,
but a future model-facing proposal should use contextual selectors or a
documented request-local-label exception rather than durable IDs.
## Compatibility And Operational Effects
This work intentionally changes private prompt inputs, private structured
responses, and mapping semantics. It will invalidate affected checkpoints
through existing prompt, schema, projection, and policy fingerprints. No
manual checkpoint migration is required.
Durable D&D artifacts and generated-reference compatibility remain unchanged.
Operators do not receive new configuration fields or CLI controls. The feature
does not change PromptKit, provider routing, profile selection, retries,
concurrency, or public output placement.
## Non-Goals
This work does not:
- remove canonical IDs from durable registries or occurrence artifacts;
- change occurrence categories, evidence rules, or registry identity policy;
- add fuzzy, probabilistic, or embedding-based entity resolution;
- allow registry provenance to substitute for occurrence evidence;
- introduce a general entity graph or cross-artifact identity framework;
- redesign unrelated D&D prompts or their schemas;
- implement the future generic deduplication normalizer; or
- add provider-specific prompt behavior.
## Acceptance Criteria
The target state is complete when:
- no maintained D&D prompt requires a model to reproduce a durable opaque
entity ID;
- current D&D reconciliation prompts no longer require opaque candidate keys;
- NPC, item, and location occurrence LLM outputs are resolved
deterministically into their unchanged durable artifacts;
- same-name location and reconciliation cases remain safe and unambiguous;
- invalid contextual selections preserve the existing extraction-failure or
normalization-fallback semantics appropriate to their stage;
- affected checkpoint identities change without altering public schema
versions;
- focused and repository-wide tests pass offline;
- the ADR, architecture invariant, D&D internal guide, LLM internal guide,
relevant integration contracts, and future roadmap accurately describe
their canonical portions of the implemented policy; and
- no unrelated code, prompt behavior, or public contract changes are included.

View File

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

View File

@@ -1,612 +0,0 @@
# Contextual Entity Grounding Implementation Plan
## Objective
Implement [Contextual Entity Grounding](contextual-entity-grounding.md) so D&D
LLM prompts return evidence-grounded contextual selectors while Notarius owns
canonical entity IDs and referential integrity. Preserve every durable D&D
artifact contract and remove opaque IDs only from model-visible inputs and
private model responses.
This plan is written for a gpt-5.6-terra coding agent. Implement the stages in
numeric order. Each stage is intentionally scoped to one implementation prompt
and must leave the repository buildable and its focused tests passing before
the next stage begins.
## Plan-Wide Decisions
Apply these decisions throughout every stage:
- Read `docs/development.md`, all files under `docs/policy/`, the feature
roadmap, and the focused implementation/tests named by the stage before
editing.
- Preserve the fixed pipeline, typed artifact boundaries, root `assets`
content-only rule, module ownership, PromptKit boundary, and evidence rules.
- Do not change the durable NPC-, item-, location-registry, or occurrence Go
types, JSON schemas, schema IDs, schema versions, media types, reference-slot
contracts, categories, or generated-reference compatibility.
- Keep the affected prompt and private response-schema identities at `v1`.
They are private pre-release transport contracts; their changed content
hashes provide the required compatibility boundary.
- Advance semantic policy identifiers exactly as directed in each stage. Do
not bump unrelated policy identifiers.
- A contextual name match uses the entity family's existing comparison policy,
never fuzzy matching. A model selection must resolve to exactly one supplied
record before a durable ID is attached.
- An invalid NPC, item, or location selection invalidates the complete
extraction operation. Do not silently drop one response record, accept a
partial artifact, or defer a known mapping failure to a later validator.
- Registry provenance remains grounding only. Only the current extraction
chunk's `source_refs` become occurrence evidence.
- Preserve prompt message order and cache controls unless a stage explicitly
directs otherwise. Edit only the selected module or shared assets; do not
rewrite unrelated shared prompt bytes.
- Preserve internal opaque IDs where deterministic code needs them. The rule
applies to material shown to the model or requested from it, not to maps,
fingerprints, checkpoints, diagnostics, or durable artifacts.
- Follow `docs/policy/testing.md`: test package-level behavior and meaningful
failure modes, not prompt prose, exact message counts, private helper
structure, or a repository-wide string-scanning change detector. All tests
remain deterministic, offline, and credential-free.
- Use `apply_patch` for edits, `gofmt` changed Go files, and preserve unrelated
worktree changes.
## Final Private Selector Contracts
These shapes are implementation requirements, not public artifact schemas.
### NPC occurrence response
Each response record contains exactly the required fields `name`, `kind`, and
`source_refs`. It does not contain `npc_id`. Notarius resolves `name` through
the normalized NPC registry and writes the matched registry record's `ID` and
canonical `Name` into the durable occurrence.
### Item occurrence response
Each response record contains the existing required `name`, `kind`,
`quantity`, `from`, `to`, and `source_refs` fields. It does not contain
`item_id`. Retain the current nullable representation and kind-specific
semantics. Notarius resolves `name` through the normalized item registry and
adds the matched `ID` and canonical `Name`.
### Location occurrence response
Each response record contains exactly the required fields `name`,
`registry_refs`, `kind`, and `source_refs`. `registry_refs` is always an array
of strict objects containing required integer `start_unit_id` and
`end_unit_id`; it may be empty.
- When `name` has one comparison-identity match in the supplied registry,
`registry_refs` must be empty and name resolution selects that record.
- When multiple registry records share the comparison identity,
`registry_refs` must equal one record's complete canonically ordered source
ranges with `source_id` removed.
- The model-facing registry projection uses the same `name` plus
`registry_refs` selector and adds a required `context` array. Unique-name
records project empty `registry_refs` and `context` arrays. The private
response does not reproduce `context`.
- Same-name records receive bounded identity context consisting of the ordered
source units covered by their registry ranges. Each context element is a
strict object with exactly the required fields `unit_id` (integer) and
`text` (string); do not expose the durable location ID, source ID, digest,
or a replacement token.
- Building same-name grounding validates that every registry range belongs to
and resolves against the current source. If two records still produce the
same contextual selector, grounding construction fails before the LLM call.
- `registry_refs` never flow into the durable occurrence's `source_refs`.
### Entity-reconciliation response
The shared response remains an object with required `duplicate_groups`.
Every group has required `members` and `canonical`. A member and the canonical
selection are strict contextual objects containing:
```json
{
"name": "Mira Thorn",
"source_refs": [
{"start_unit_id": 12, "end_unit_id": 12}
]
}
```
The candidate prompt input uses the same descriptor and contains no `key`.
`source_refs` is required and non-empty for every eligible candidate. The
shared helper may retain its existing `candidate-000001`-style keys strictly
inside Go state to preserve input-position mapping; those keys must never be
serialized into prompt input or accepted in the private response.
If two candidates produce an identical contextual descriptor, neither is
eligible for model-assisted reconciliation because the model cannot identify
them independently. Otherwise the helper converts returned descriptors to its
internal candidate keys before applying all existing unknown-member,
ineligible-member, duplicate-member, canonical-membership, overlap, retry, and
fallback rules.
## Stage 1: Convert NPC Occurrences To Name-Based Resolution
### Goal
Remove durable NPC IDs from the NPC-occurrence prompt and private response,
then resolve the model's contextual name deterministically without weakening
checkpoint identity or downstream validation.
### Work
1. Inspect:
- `assets/dnd/npc-occurrences/`;
- `internal/modules/dnd/extract/npcoccurrences/`;
- `internal/modules/dnd/npcs/registry/`;
- NPC-occurrence normalizer and validator checkpoint fingerprints; and
- their focused tests.
2. Change `dnd_npc_occurrences_llm.v1.json` so every occurrence requires only
`name`, `kind`, and `source_refs`, continues to reject unknown fields, and
no longer declares `npc_id`.
3. Revise the NPC-occurrence instructions to require a supplied canonical NPC
name and current-chunk evidence, with no instruction to copy or invent an
ID. Continue using the existing shared names-only NPC registry fragment and
preserve manifest order/cache controls.
4. Remove `NPCID` from the private `occurrenceResponse`. After canonicalizing
response evidence, resolve every response name with the existing
`npcregistry.Registry.Lookup` comparison-key lookup. On the first unknown
or non-unique selection, return an extractor-scoped mapping error and no
value. For a match, construct the durable occurrence with the registry
record's exact `ID` and canonical `Name`.
5. Change `mappingPolicy` to
`dnd.npc_occurrences.extract_mapping.v3`.
6. Stop passing `IdentityPromptInput()` to the LLM; use the existing
names-only `PromptInput()`.
7. Replace the misleading exported model-input API used only for identity
fingerprints: retain the unexported ordered `{id,name}` projection and its
digest, expose that value as `IdentityDigest() string`, remove
`IdentityPromptInput()`, and update NPC-occurrence extractor, normalizer,
invariant-validator, and registry-validator fingerprints to use
`IdentityDigest()`. The digest must still distinguish ID/name identity from
the names-only prompt projection.
8. Rewrite existing focused tests around observable behavior: rendered NPC
registry input is names-only; the private schema rejects `npc_id`; valid
names acquire the registry ID; comparison-equivalent names canonicalize;
unknown names fail the whole extraction; registry identity fingerprints
remain distinct and defensive; empty registries accept only empty model
results. Remove tests whose only purpose was requiring the model to return
exact ID/name pairs.
### Acceptance Criteria
- No NPC-occurrence LLM input or private response contains a durable NPC ID.
- Durable NPC occurrences still contain the exact registry ID/name pair.
- Mapping failures remain extractor failures eligible for the configured
pipeline retry behavior.
- Deterministic consumers still fingerprint the ordered registry identity,
while spells, combat turns, enemy events, and NPC occurrences share the
names-only model projection.
### Validation
```sh
go fmt ./internal/modules/dnd/npcs/registry ./internal/modules/dnd/extract/npcoccurrences ./internal/modules/dnd/normalize/npcoccurrences ./internal/modules/dnd/validate/npcoccurrences/...
go test ./internal/modules/dnd/npcs/registry ./internal/modules/dnd/extract/npcoccurrences ./internal/modules/dnd/normalize/npcoccurrences ./internal/modules/dnd/validate/npcoccurrences/...
```
This stage is suitable for one gpt-5.6-terra prompt.
## Stage 2: Convert Item Occurrences To Name-Based Resolution
### Goal
Give item occurrences the same contextual-name/deterministic-ID boundary while
preserving item-specific nullable fields and kind rules.
### Work
1. Inspect `assets/dnd/item-occurrences/`, the item occurrence extractor, the
item registry, the item occurrence normalizer and registry validator, and
their focused tests.
2. Change `dnd_item_occurrences_llm.v1.json` to remove `item_id` from required
fields and properties. Preserve required `name`, `kind`, `quantity`, `from`,
`to`, and `source_refs`, all current enums/nullability, and strict unknown
field rejection.
3. Rewrite the item registry fragment and module instructions to require the
supplied canonical name and current-chunk evidence without mentioning an
ID. Preserve prompt order and cache controls.
4. Change the item registry's model projection from ordered `{id,name}` pairs
to ordered names-only objects, add a comparison-key index, and expose a
defensive `Lookup(name) (dnd.Item, bool)` analogous to the NPC registry.
Retain exact `LookupID` for durable normalizers and validators. Because item
IDs are derived from the item comparison identity, the names-only
`ProjectionDigest` remains sufficient for model input and existing
checkpoint consumers.
5. Remove `ItemID` from the private response. During response canonicalization,
resolve every contextual name, replace it with the registry record's
canonical name, and attach its durable ID when constructing the final
`dnd.ItemOccurrence`. Unknown selections fail the complete extraction; do
not alter evidence or nullable-field validation ownership.
6. Change `mappingPolicy` to
`dnd.item_occurrences.extract_mapping.v2`.
7. Update focused tests to cover names-only projection, defensive comparison
lookup, schema rejection of `item_id`, deterministic durable mapping,
unknown-name failure after an otherwise valid record, empty registry/result
behavior, and preservation of nullable/kind-specific fields.
### Acceptance Criteria
- Model-visible item registry and response content contain no item hash.
- Every accepted durable occurrence has the matched registry ID and canonical
name.
- Invalid selection remains all-or-nothing, and existing normalizer/validator
defense in depth remains unchanged.
### Validation
```sh
go fmt ./internal/modules/dnd/items/registry ./internal/modules/dnd/extract/itemoccurrences
go test ./internal/modules/dnd/items/registry ./internal/modules/dnd/extract/itemoccurrences ./internal/modules/dnd/normalize/itemoccurrences ./internal/modules/dnd/validate/itemoccurrences/...
```
This stage is suitable for one gpt-5.6-terra prompt.
## Stage 3: Add Contextual Location Grounding
### Goal
Replace the location registry's ID/name prompt projection with an immutable,
source-aware grounding object that can represent same-name locations safely.
Introduce the new path alongside the old occurrence input so this stage remains
buildable; Stage 4 performs the atomic extractor cutover and removes the old
path.
### Work
1. Inspect the location registry, location identity and source-reference
helpers, the generic source document index, occurrence checkpoint consumers,
and their focused tests.
2. In `internal/modules/dnd/locations/registry`, define the private-model
types needed by both grounding and the location occurrence extractor:
- a returned selector with exactly `name` and `registry_refs`;
- a registry projection entry with exactly `name`, `registry_refs`, and
`context`;
- a source-free range with exactly `start_unit_id` and `end_unit_id`; and
- a context unit with exactly `unit_id` and `text`.
All fields are required in their private JSON shapes, and constructors and
accessors must make defensive copies.
3. Add an operation-scoped immutable grounding type constructed from a resolved
registry and the current `*source.SourceDocument`. Its API must provide:
- a cloned `contracts.LLMInputMaterial` for the `location_registry` slot;
- deterministic resolution of a returned selector to one cloned
`dnd.Location`.
The prompt material's existing `Digest` field owns the digest of the exact
model projection; do not expose a second grounding-specific digest API.
4. Construct the projection in registry order. Group entries by the existing
location comparison key:
- every projection entry has exactly the required fields `name`,
`registry_refs`, and `context`;
- comparison-unique entries use empty `registry_refs` and `context` arrays;
- every same-name entry uses its complete canonical source ranges stripped
of `source_id` and includes ordered context units covered by those ranges;
- each context unit contains exactly required integer `unit_id` and string
`text` fields, and units are deduplicated in source order; and
- same-name ranges must have `SourceID == doc.ID` and pass
`source.DocumentIndex.ValidateRef`.
5. Fail grounding construction with a bounded, content-safe error if the
source is nil, a same-name range is invalid or belongs to another source,
a comparison key is empty, or two records produce the same selector. Do not
expose transcript text in the error.
6. Resolution uses the existing comparison key. A unique-name selector is
accepted only with empty `registry_refs`; a same-name selector is accepted
only on an exact canonical range match. Reject unknown names, a non-empty
range list for a unique name, an empty/partial/reordered range list for an
ambiguous name, or any selector not present in the grounding.
7. Separate deterministic identity fingerprinting from LLM material. Add
`IdentityDigest()` over the registry's ordered `{id,name}` identity
projection, and update the location normalizer and registry-validator
checkpoint consumers to use it. The new operation grounding carries the
model projection digest in its `LLMInputMaterial`. Retain the old ID-bearing
prompt accessor only as a documented transitional dependency of the
still-unchanged location occurrence extractor; do not add new callers.
8. Add focused tests for unique names, same-name context and selectors,
canonical range order, deterministic projection/digest, defensive copies,
exact selector resolution, nil/foreign/invalid references, selector
collisions, empty registries, and identity fingerprint stability. Do not
assert large rendered prompt strings; decode the JSON projection and assert
its semantic shape.
### Acceptance Criteria
- The location package can build and resolve contextual selectors without
exposing `location_id`, `source_id`, digests, or replacement labels.
- Same-name locations remain distinct and receive meaningful bounded context.
- Deterministic checkpoint consumers retain an ID-sensitive fingerprint.
- Only the existing occurrence extractor remains wired to the legacy
ID-bearing prompt path until Stage 4; the repository compiles and tests pass.
### Validation
```sh
go fmt ./internal/modules/dnd/locations/registry ./internal/modules/dnd/normalize/locationoccurrences ./internal/modules/dnd/validate/locationoccurrences/...
go test ./internal/modules/dnd/locations/registry ./internal/modules/dnd/normalize/locationoccurrences ./internal/modules/dnd/validate/locationoccurrences/...
```
This stage is suitable for one gpt-5.6-terra prompt.
## Stage 4: Convert Location Occurrences To Contextual Resolution
### Goal
Wire the Stage 3 grounding object into location occurrence extraction and
remove durable location IDs from the prompt and private response.
### Work
1. Inspect `assets/dnd/location-occurrences/`, the location occurrence model,
schema loader, extractor, canonicalization, prompt tests, and Stage 3
grounding tests.
2. Change `dnd_location_occurrences_llm.v1.json` so each occurrence requires
exactly `name`, `registry_refs`, `kind`, and `source_refs`; remove
`location_id`. Keep all four occurrence kinds. Make `registry_refs` a
required array, including an empty array, of strict required positive
integer ranges. Keep occurrence `source_refs` separate and unchanged.
3. Rewrite `location-registry.md` and module instructions to explain the two
selector cases, require exact supplied contextual selectors, prohibit
invented locations, and state that registry ranges/context are identity
grounding rather than occurrence evidence. Preserve manifest order and
cache controls.
4. Change the private response type to `Name`, `RegistryRefs`, `Kind`, and
`SourceRefs`. Do not reuse `source.SourceRef` for the source-free registry
range type.
5. In `Extract`, construct operation grounding from the resolved registry and
`req.Source` before calling the LLM, put its projection in the
`location_registry` input, and resolve every returned selector after
completion. Attach the selected registry record's exact ID and canonical
name to the durable occurrence while retaining only the response's
current-source `source_refs` as evidence.
6. Fail the whole extraction on grounding-construction failure or the first
unknown, malformed, mismatched, or ambiguous selector. This replaces the
current behavior that can preserve unknown ID/name pairs for later
validators. Keep later normalizer and validator checks as defense in depth
for artifacts entering other boundaries.
7. Change `mappingPolicy` to
`dnd.location_occurrences.extract_mapping.v2`.
8. Remove the legacy ID-bearing registry `PromptInput` and its model-projection
digest once the extractor uses operation grounding. Keep the occurrence's
static module fingerprint based on `IdentityDigest()`, prompt/schema
fingerprints, and mapping policy. The operation-scoped projection is already
covered by source/chunk identity and configured or generated reference
dependencies, while its `LLMInputMaterial.Digest` identifies the exact model
input; do not add a second digest API or operation-aware static fingerprint.
9. Update focused schema, prompt, extractor, canonicalization, checkpoint, and
generated-reference tests. Cover unique-name empty selectors, successful
same-name selection, failure for an unsupported ambiguous mention,
partial/reordered ranges, no registry-to-occurrence evidence leakage,
all-or-nothing failure, empty registry/result behavior, and unchanged
durable ordering/deduplication.
### Acceptance Criteria
- The location prompt and private response contain no durable location ID.
- Unique and same-name records resolve according to the final selector
contract.
- Accepted durable output is unchanged in shape and still contains an exact
location ID/name pair.
- Registry context cannot become durable occurrence evidence.
### Validation
```sh
go fmt ./internal/modules/dnd/extract/locationoccurrences ./internal/modules/dnd/locations/registry
go test ./internal/modules/dnd/locations/registry ./internal/modules/dnd/extract/locationoccurrences ./internal/modules/dnd/normalize/locationoccurrences ./internal/modules/dnd/validate/locationoccurrences/...
```
This stage is suitable for one gpt-5.6-terra prompt.
## Stage 5: Replace Reconciliation Keys With Contextual Descriptors
### Goal
Change the shared NPC/item/location registry-normalization proposal contract so
opaque candidate keys remain internal and the model sees and returns only
names plus evidence coordinates.
### Work
1. Inspect:
- `internal/modules/dnd/shared/entityreconcile/`;
- `assets/dnd/shared/prompts/common-dnd-entity-reconciliation.md`;
- `assets/dnd/entity-reconciliation/schemas/`;
- all three registry normalization manifests and prompt tests; and
- the NPC, item, and location registry normalizers and reconciliation tests.
2. Introduce one exported, defensively copied contextual selector type in
`entityreconcile` with JSON `name` and `source_refs`, plus a strict
source-free range type. Use it for candidate input views and for
`DuplicateGroup.Members` and `.Canonical`.
3. Keep deterministic candidate keys only inside `Materials`. During
`BuildContext`, validate and canonicalize candidate references as today,
serialize candidate views without `key`, derive a stable internal lookup
from canonical selector JSON to the corresponding internal candidate key,
and detect descriptor collisions before eligibility is established.
Colliding candidates must not appear in the prompt input or become
eligible; their records remain in deterministic normalization output.
4. Update `Materials.Assess` to resolve every returned selector through that
internal lookup before running the existing group assessment. Preserve
existing issue categories where their meaning still applies. Treat an
unknown or collided descriptor as an unknown/ineligible selection, discard
only the affected group, and retain existing overlap handling. `SafeGroup`
may continue returning internal candidate keys so the three domain
normalizers retain their position mapping; those keys are not model-facing.
5. Rewrite `dnd_entity_reconcile_llm.v1.json` so members and canonical are
strict selector objects. Require non-empty `name` structurally where the
current schemas do so, require `source_refs`, and make each range strict
with required positive integer endpoints. Preserve `duplicate_groups` and
the existing semantic assessment of minimum group size, membership,
duplicates, eligibility, and overlap rather than moving every semantic
failure into JSON Schema.
6. Rewrite the shared reconciliation fragment to tell the model to return
supplied contextual descriptors and never invent names or ranges. Remove
every instruction about opaque keys. Preserve all three manifests' message
ordering and cache controls.
7. Change registry normalization policy identifiers to:
- `dnd.npc_registry.normalize.v4`;
- `dnd.item_registry.normalize.v2`; and
- `dnd.location_registry.normalize.v2`.
8. Update shared and domain tests to cover candidate JSON without keys,
contextual proposal decoding, valid selector-to-internal-key mapping,
equal names with different evidence, descriptor collision exclusion,
unknown/partial/reordered descriptors, overlapping groups, canonical
membership, invalid structured-output fallback, currency safety, and
preservation of every non-applied deterministic candidate. Update prompt
asset fixtures to the new selector schema; do not snapshot prompt prose.
### Acceptance Criteria
- No registry normalization prompt input or private response contains a
`candidate-*` key.
- Internal keys remain inaccessible to the model but may still support safe
deterministic position mapping.
- All existing normalizer safety, retry, fallback, warning, currency, and
same-name-location policies remain intact.
- Identical contextual descriptors cannot be arbitrarily reconciled.
### Validation
```sh
go fmt ./internal/modules/dnd/shared/entityreconcile ./internal/modules/dnd/normalize/npcregistry ./internal/modules/dnd/normalize/itemregistry ./internal/modules/dnd/normalize/locationregistry
go test ./internal/modules/dnd/shared/entityreconcile ./internal/modules/dnd/normalize/npcregistry ./internal/modules/dnd/normalize/itemregistry ./internal/modules/dnd/normalize/locationregistry
```
This is the largest stage, but it is one cohesive shared-contract migration
and is suitable for one gpt-5.6-terra prompt when implemented exactly within
the listed packages. Do not combine it with occurrence or documentation work.
## Stage 6: Record The Decision And Update Canonical Documentation
### Goal
Document the implemented policy in its durable architectural, internal, and
integration homes without duplicating volatile details or presenting roadmap
work as current behavior prematurely.
### Work
1. Re-read `docs/policy/documentation.md`, ADR-0003, ADR-0009, ADR-0011,
`docs/internal/dnd.md`, `docs/internal/llm.md`, and the six affected registry
and occurrence integration documents. Verify the code before describing it.
2. Add
`docs/adr/0012-resolve-opaque-entity-identifiers-deterministically.md` in
the repository's Nygard ADR format with status `Accepted` and the actual
implementation date. Record the model-semantic/deterministic-identity
boundary, source-coordinate allowance, request-local-label exception,
alternatives, ambiguity behavior, and consequences. Link ADR-0003 and
ADR-0009 rather than repeating their complete decisions.
3. Add a concise normative invariant under the LLM boundary in
`docs/policy/architecture.md`: callers use contextual model selections and
attach opaque application identities deterministically when possible. Link
ADR-0012 for rationale.
4. Update `docs/internal/dnd.md` to replace exact model-facing `{id,name}`
claims with the implemented NPC/item names-only and location contextual
selector behavior. Document reconciliation descriptors, internal-only keys,
all-or-nothing occurrence mapping failures, normalization fallback, and the
separation between registry and occurrence evidence. Do not duplicate the
private JSON schemas.
5. Add only a short ownership clarification to `docs/internal/llm.md`: the
calling module resolves contextual selections; PromptKit and its adapter do
not own entity identity.
6. Update these durable integration contracts while preserving their public
ID-bearing wire examples and schema statements:
- `docs/integrations/dnd-npc-registry-artifacts.md`;
- `docs/integrations/dnd-npc-occurrence-artifacts.md`;
- `docs/integrations/dnd-item-registry-artifacts.md`;
- `docs/integrations/dnd-item-occurrence-artifacts.md`;
- `docs/integrations/dnd-location-registry-artifacts.md`; and
- `docs/integrations/dnd-location-occurrence-artifacts.md`.
Remove claims that LLM consumers receive `{id,name}` or that raw model
output supplies an ID. State that Notarius maps contextual output into the
unchanged exact durable pair.
7. Revise the generic LLM-assisted deduplication entry in
`docs/roadmap/future.md`: stable unique IDs remain internal deterministic
state, while a future model proposal uses contextual descriptors or a
specifically justified request-local short label.
8. Do not change README, CLI, configuration, operations, examples, or public
schema files; this feature has no user-selectable surface or public wire
change.
### Acceptance Criteria
- ADR-0012 owns rationale; architecture owns the normative boundary; internal
docs own mechanics; integration docs own unchanged durable contracts; and
the future roadmap no longer proposes durable IDs as the default model
selector.
- No current-behavior document claims that a model copies hash-based entity
IDs or opaque reconciliation keys.
- Documentation does not duplicate private schemas or implementation history.
### Validation
```sh
git diff --check
rg -n '\{id,name\}|ID/name grounding|Candidate keys are opaque|candidate-[0-9]' docs assets/dnd
```
Review every search result semantically; durable wire-contract ID/name
requirements and internal test fixtures are not automatically errors.
This stage is suitable for one gpt-5.6-terra prompt.
## Stage 7: Integration Audit And Final Verification
### Goal
Verify the assembled D&D family, remove obsolete identity-copy paths, and
finish with a clean, policy-compliant implementation.
### Work
1. Audit every maintained D&D prompt manifest, selected fragment, private
schema, and constructed prompt projection. Confirm that no model is asked to
reproduce `npc:sha256:...`, `item:sha256:...`,
`location:sha256:...`, `candidate-*`, a UUID, a digest, or another opaque
entity handle. Do not confuse runtime metadata or durable output contracts
with model-visible material.
2. Trace all former APIs and fields, including `IdentityPromptInput`,
ID-bearing item/location prompt projections, private `NPCID`/`ItemID`/
`LocationID` response fields, and model-visible candidate keys. Remove dead
code, obsolete comments, stale test names, and unused assets. Retain
identity-only digests and exact durable lookup APIs used by deterministic
consumers.
3. Review prompt fingerprint registration and checkpoint fingerprints. Confirm
that each affected prompt/schema/policy/projection change invalidates the
relevant operation and that unrelated D&D lanes retain their existing
fingerprints.
4. Run representative production registration and multi-step pipeline tests
using existing fakes. Update only tests whose stable behavior changed.
Confirm generated NPC/item/location registry handoffs still prepare and
that final durable occurrences encode and validate under their existing
`v1` codecs.
5. Run formatting, focused suites, full tests, vet, build, and documentation
whitespace checks. Fix only failures caused by this feature. Report any
unrelated pre-existing failure without broadening scope.
6. Review the feature roadmap acceptance criteria one by one. Do not delete
`contextual-entity-grounding.md` or this implementation plan in this stage;
roadmap retirement is a separate maintainer action after review.
### Acceptance Criteria
- All feature-roadmap acceptance criteria are met.
- The repository contains no obsolete model-facing opaque-identity path.
- Public artifacts and generated handoffs remain compatible.
- Tests are focused on behavior rather than prose or implementation shape.
- The worktree contains only intentional feature and documentation changes.
### Validation
```sh
go fmt ./internal/modules/dnd/...
go test ./internal/modules/dnd/...
go test ./internal/modules/integration/...
go test ./...
go vet ./...
go build ./cmd/notarius
git diff --check
git status --short
```
This stage is suitable for one gpt-5.6-terra prompt.

View File

@@ -0,0 +1,81 @@
package cli
import (
"context"
"errors"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
const invalidEnemyEventExtractorKey = "test/dnd/invalid-enemy-events"
func TestAssembledEnemyEventLaneRejectsInvalidFinalArtifactDespiteValidatorOverrides(t *testing.T) {
components := productionTestComponents(t)
if err := pipeline.RegisterExtractor[dnd.EnemyEventList](components.registries.Extractors, pipeline.ModuleSpec{
Key: invalidEnemyEventExtractorKey,
Stage: pipeline.StageExtract,
ExecutionClass: contracts.ExecutionClassDeterministic,
Requires: []string{"chunks", "source.transcript"},
Provides: []string{"dnd.enemy_events"},
ArtifactKind: dnd.EnemyEventListKind,
}, func() (contracts.Extractor[dnd.EnemyEventList], error) {
return invalidEnemyEventExtractor{}, nil
}); err != nil {
t.Fatalf("register extractor: %v", err)
}
accept := pipeline.ValidatorOverride{Set: true, Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}}
resolved, err := pipeline.ResolvePipeline(pipeline.PipelineProfile{
ID: "assembled-invalid-enemy-events",
Input: pipeline.Binding("seriatim"),
Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}},
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"enemy-events": {
Extract: pipeline.ModuleBinding{Module: invalidEnemyEventExtractorKey, Validators: accept},
Normalize: pipeline.ModuleBinding{Module: pipeline.DefaultNormalizeModule, Validators: accept},
},
},
Output: pipeline.Binding("json"),
}, pipeline.ResolveOptions{}, catalogFromRegistries(components.registries))
if err != nil {
t.Fatalf("ResolvePipeline() error = %v", err)
}
prepared, err := pipeline.Prepare(resolved, components.registries, pipeline.ModuleDependencies{})
if err != nil {
t.Fatalf("Prepare() error = %v", err)
}
_, err = pipeline.New().Run(context.Background(), pipeline.RunInput{
Prepared: prepared,
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
ChunkCacheMode: pipeline.ChunkCacheBypass,
})
if err == nil || !strings.Contains(err.Error(), "serialize accepted extract output") || !strings.Contains(err.Error(), "must not exceed") {
t.Fatalf("Run() error = %v, want final durable range rejection", err)
}
}
type invalidEnemyEventExtractor struct{}
func (invalidEnemyEventExtractor) Key() string { return invalidEnemyEventExtractorKey }
func (invalidEnemyEventExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (invalidEnemyEventExtractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.EnemyEventList], error) {
if err := ctx.Err(); err != nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, err
}
if req.Source == nil {
return contracts.TypedExtractionResult[dnd.EnemyEventList]{}, errors.New("assembled extractor requires source")
}
return contracts.TypedExtractionResult[dnd.EnemyEventList]{Value: dnd.EnemyEventList{Events: []dnd.EnemyEvent{{
Name: "Ashfang",
Kind: dnd.EnemyEventKindEngaged,
SourceRefs: []source.SourceRef{{SourceID: req.Source.ID, StartUnitID: 2, EndUnitID: 1}},
}}}}, nil
}

View File

@@ -134,7 +134,7 @@ func TestAssembledSpellPipelineHonorsNormalizeValidatorOverride(t *testing.T) {
}
}
func TestAssembledSpellPipelineRejectsUnknownSpellWithoutPromotingAttemptWarning(t *testing.T) {
func TestAssembledSpellPipelinePromotesTerminalUnknownSpellWarning(t *testing.T) {
registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{unknownSpell: true})
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
if err != nil {
@@ -161,10 +161,8 @@ func TestAssembledSpellPipelineRejectsUnknownSpellWithoutPromotingAttemptWarning
if !reflect.DeepEqual(rejectedFile.Rejected, output.Rejected) {
t.Fatalf("rejected file = %#v, run rejections = %#v, want durable rejection diagnostic", rejectedFile.Rejected, output.Rejected)
}
for _, warning := range output.Warnings {
if warning.ReasonCode == spellnormalize.ReasonCodeSpellNameUnresolved {
t.Fatalf("warnings = %#v, want rejected-attempt warning to remain non-durable", output.Warnings)
}
if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != spellnormalize.ReasonCodeSpellNameUnresolved || output.Warnings[0].Scope != "spell_casts[0]" {
t.Fatalf("warnings = %#v, want terminal normalize catalog warning", output.Warnings)
}
}

View File

@@ -189,10 +189,18 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
}
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 !containsString(evidence.SelectedLanes, laneID) || !evidenceHasLane(evidence, laneID) {
t.Fatalf("evidence context = %#v, want direct %s evidence", evidence, laneID)
if len(evidence) == 0 {
t.Fatalf("evidence context = %#v, want selected source-unit evidence", evidence)
}
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)
@@ -330,7 +338,7 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
if len(registry.Items) != 1 || registry.Items[0].Name != "Moonblade" {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated item registry has %d items, want 1", len(registry.Items))
}
content = []byte(`{"occurrences":[{"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":5,"end_segment":5}]}]}`)
content = []byte(`{"occurrences":[{"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_unit_id":5,"end_unit_id":5}]}]}`)
}
case combat.PromptID:
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`)
@@ -415,17 +423,6 @@ func containsString(values []string, want string) bool {
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) {
for _, binding := range bindings {
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/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
@@ -311,6 +312,47 @@ func TestProductionPromptAssetsPrepareWithoutProviderCredentials(t *testing.T) {
if _, err := pipeline.Prepare(effective.ResolvedPipeline, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil {
t.Fatalf("prepare production scene and spell modules: %v", err)
}
schemaFS, err := components.assets.SchemaFS()
if err != nil {
t.Fatalf("production schema assets: %v", err)
}
if _, err := fs.ReadFile(schemaFS, filepath.Base(semanticreconcile.SchemaAssetPath)); err != nil {
t.Fatalf("generic reconciliation schema asset: %v", err)
}
options, err := components.assets.PromptKitOptions()
if err != nil {
t.Fatalf("production PromptKit options: %v", err)
}
options = append(options, promptkit.WithProfiles(promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
ID: "assembled-prompt-test", Endpoint: "http://127.0.0.1:1/v1", Model: "test",
})))
engine, err := promptkit.NewEngine(promptkit.Config{}, options...)
if err != nil {
t.Fatalf("production prompt engine: %v", err)
}
inputs := map[string]promptkit.ArtifactRef{
"candidates": promptkit.Inline(`{"candidates":[{"candidate_id":1,"label":"Alias","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
"transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`),
}
for _, prompt := range []struct {
id string
version string
}{
{id: npcnormalize.PromptID, version: npcnormalize.PromptVersion},
{id: itemregistrynormalize.PromptID, version: itemregistrynormalize.PromptVersion},
{id: locationnormalize.PromptID, version: locationnormalize.PromptVersion},
} {
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: prompt.id, PromptVersion: prompt.version, ProfileID: "assembled-prompt-test", Inputs: inputs,
})
if err != nil {
t.Fatalf("prepare production prompt %q: %v", prompt.id, err)
}
if prepared.OutputContract.SchemaPath != filepath.Base(semanticreconcile.SchemaAssetPath) {
t.Fatalf("prompt %q schema = %q, want generic reconciliation schema", prompt.id, prepared.OutputContract.SchemaPath)
}
}
}
func TestProductionSpellValidatorsPrepareFromMaterializedCatalog(t *testing.T) {

View File

@@ -400,6 +400,9 @@ func (referenceContractCodecA) Encode(stateTestArtifact) ([]byte, error) {
func (referenceContractCodecA) Decode([]byte) (stateTestArtifact, error) {
return stateTestArtifact{Value: "ok"}, nil
}
func (codec referenceContractCodecA) DecodeCandidate(content []byte) (stateTestArtifact, error) {
return codec.Decode(content)
}
func (referenceContractCodecB) Kind() contracts.ArtifactKind { return referenceContractKindBeta }
func (referenceContractCodecB) Schema() contracts.ArtifactSchema {
@@ -415,6 +418,9 @@ func (referenceContractCodecB) Encode(stateTestArtifact) ([]byte, error) {
func (referenceContractCodecB) Decode([]byte) (stateTestArtifact, error) {
return stateTestArtifact{Value: "ok"}, nil
}
func (codec referenceContractCodecB) DecodeCandidate(content []byte) (stateTestArtifact, error) {
return codec.Decode(content)
}
func referenceContractLane(t *testing.T, resolved pipeline.ResolvedPipeline, id string) pipeline.ResolvedArtifactLane {
t.Helper()

View File

@@ -19,6 +19,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle"
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
"gitea.maximumdirect.net/eric/notarius/internal/framework/checkpoint"
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkplan"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
@@ -147,7 +148,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
machineOutput := fs.Bool("json", false, "write the successful run result as JSON")
debug := fs.Bool("debug", false, "write a debug bundle")
debugDir := fs.String("debug-dir", "", "debug bundle directory")
llmProfile := fs.String("llm-profile", "", "LLM profile override")
llmProfile := singleValueFlag{name: "--llm-profile"}
reasoningEffort := singleValueFlag{name: "--reasoning-effort"}
clearReasoningEffort := fs.Bool("clear-reasoning-effort", false, "clear the LLM profile reasoning effort")
resume := fs.Bool("resume", false, "reuse compatible recorded checkpoints")
@@ -157,6 +158,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
referenceFlags := stringListFlag{}
withoutReferenceFlags := stringListFlag{}
fs.Var(&requestedSessionID, "session-id", "prompt session identifier")
fs.Var(&llmProfile, "llm-profile", "LLM profile override")
fs.Var(&reasoningEffort, "reasoning-effort", "reasoning effort override")
fs.Var(&chunkCache, "chunk_cache", "chunk plan cache mode: auto, bypass, or refresh")
fs.Var(&referenceFlags, "reference", "reference binding, as slot=path, chunk.slot=path, merge.slot=path, lane.slot=path, lane.extract.slot=path, lane.merge.slot=path, or lane.normalize.slot=path")
@@ -203,6 +205,10 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
fmt.Fprintln(stderr, "notarius: --session-id must not be empty")
return 2
}
if llmProfile.set && strings.TrimSpace(llmProfile.value) == "" {
fmt.Fprintln(stderr, "notarius: --llm-profile must not be empty")
return 2
}
if reasoningEffort.set && *clearReasoningEffort {
fmt.Fprintln(stderr, "notarius: --reasoning-effort cannot be combined with --clear-reasoning-effort")
return 2
@@ -338,7 +344,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
PipelineID: pipelineID,
Only: only,
Catalog: catalog,
LLMProfileOverride: *llmProfile,
LLMProfileOverride: strings.TrimSpace(llmProfile.value),
ReferenceOverrides: referenceOverrides,
ReferenceUnbinds: referenceUnbinds,
})
@@ -426,7 +432,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
if err != nil {
return failPipelineCommand(stderr, commandState, terminalWriter, err)
}
checkpointRecorder, checkpointLoader, err := checkpointHandlersForRun(effective.Config.Cache.Checkpoints, opts, effective.ResolvedPipeline, prepared.CheckpointFingerprints(), llmFingerprints, rawInput, only, llmProfiles, strings.TrimSpace(*llmProfile), effectiveSessionID, runtimeOverrides, *resume)
checkpointRecorder, checkpointLoader, err := checkpointHandlersForRun(effective.Config.Cache.Checkpoints, opts, effective.ResolvedPipeline, prepared.CheckpointFingerprints(), llmFingerprints, rawInput, only, llmProfiles, strings.TrimSpace(llmProfile.value), effectiveSessionID, runtimeOverrides, *resume)
if err != nil {
return failPipelineCommand(stderr, commandState, terminalWriter, err)
}
@@ -752,17 +758,10 @@ func configSource(configPath string) string {
}
func writeOutputFiles(runOutputDir string, files []contracts.OutputFile) error {
type outputTarget struct {
path string
file contracts.OutputFile
}
targets := make([]outputTarget, 0, len(files))
for _, file := range files {
targetPath, err := outputFilePath(runOutputDir, file.Name)
if err != nil {
if _, err := outputFilePath(runOutputDir, file.Name); err != nil {
return err
}
targets = append(targets, outputTarget{path: targetPath, file: file})
}
outputParent := filepath.Dir(runOutputDir)
@@ -775,12 +774,9 @@ func writeOutputFiles(runOutputDir string, files []contracts.OutputFile) error {
}
return fmt.Errorf("create output run directory %q: %w", runOutputDir, err)
}
for _, target := range targets {
if err := os.MkdirAll(filepath.Dir(target.path), 0o755); err != nil {
return fmt.Errorf("create output directory %q: %w", filepath.Dir(target.path), err)
}
if err := writeFileAtomic(target.path, target.file.Bytes, 0o644); err != nil {
return fmt.Errorf("write output file %q: %w", target.file.Name, err)
for _, file := range files {
if err := fileio.WriteBytes(runOutputDir, file.Name, file.Bytes, 0o755, 0o644); err != nil {
return fmt.Errorf("write output file %q: %w", file.Name, err)
}
}
return nil
@@ -823,38 +819,6 @@ func outputFilePath(runOutputDir, logicalName string) (string, error) {
return target, nil
}
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
temp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
if err != nil {
return err
}
tempPath := temp.Name()
removeTemp := true
defer func() {
if removeTemp {
_ = os.Remove(tempPath)
}
}()
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return err
}
if err := temp.Chmod(perm); err != nil {
_ = temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
if err := os.Rename(tempPath, path); err != nil {
return err
}
removeTemp = false
return nil
}
func reorderRunArgs(args []string) []string {
var flags []string
var positionals []string

View File

@@ -43,6 +43,12 @@ func TestRunControlsRejectSyntaxWithoutAllocatingState(t *testing.T) {
{name: "blank session ID", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--session-id", ""}
}},
{name: "blank LLM profile", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--llm-profile", ""}
}},
{name: "whitespace LLM profile", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--llm-profile", " \t "}
}},
{name: "multiple pipeline IDs", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "extra", "--config", roots.config, "--input", roots.input}
}},
@@ -253,7 +259,7 @@ func TestRunLLMProfileOverrideAndValidationUseInjectedBoundaries(t *testing.T) {
return nil, nil, nil
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", "override-profile"}, &stdout, &stderr, opts)
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", " override-profile "}, &stdout, &stderr, opts)
if code != 0 || stderr.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}

View File

@@ -38,10 +38,29 @@ func TestWriteOutputFilesSupportsNestedLogicalPaths(t *testing.T) {
if err := writeOutputFiles(runPath, []contracts.OutputFile{{Name: "nested/result.json", Bytes: []byte("result")}}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(runPath, "nested", "result.json"))
resultPath := filepath.Join(runPath, "nested", "result.json")
data, err := os.ReadFile(resultPath)
if err != nil || string(data) != "result" {
t.Fatalf("nested output = %q, %v", data, err)
}
for path, want := range map[string]os.FileMode{runPath: 0o755, filepath.Join(runPath, "nested"): 0o755, resultPath: 0o644} {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != want {
t.Fatalf("%s mode = %#o, want %#o", path, info.Mode().Perm(), want)
}
}
entries, err := os.ReadDir(filepath.Join(runPath, "nested"))
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.Contains(entry.Name(), ".tmp-") {
t.Fatalf("temporary file remains: %s", entry.Name())
}
}
}
func TestWriteOutputFilesRejectsUnsafeNamesBeforeAllocatingRunDirectory(t *testing.T) {
@@ -75,7 +94,7 @@ func TestWriteOutputFilesRetainsNewPartialDirectoryAndPreservesSibling(t *testin
{Name: "blocked", Bytes: []byte("partial output")},
{Name: "blocked/nested.json", Bytes: []byte("unreachable")},
})
if err == nil || !strings.Contains(err.Error(), "create output directory") {
if err == nil || !strings.Contains(err.Error(), `write output file "blocked/nested.json"`) {
t.Fatalf("writeOutputFiles() error = %v, want later directory failure", err)
}
if got, err := os.ReadFile(filepath.Join(runPath, "blocked")); err != nil || string(got) != "partial output" {

View File

@@ -410,8 +410,8 @@ func TestMaintainedProductionOverlayRunAlignsGroundingValidationAndProvenance(t
t.Fatalf("spell requests = %d, want one", len(requests))
}
catalogInput, ok := requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot]
if !ok || !strings.Contains(string(catalogInput.Content), "Aegis of Emberfall") || strings.Contains(string(catalogInput.Content), "Emberfall Aegis") {
t.Fatalf("spell catalog prompt input = %#v, want canonical overlay name without alias", catalogInput)
if !ok || !strings.Contains(string(catalogInput.Content), `"canonical_name":"Aegis of Emberfall"`) || !strings.Contains(string(catalogInput.Content), `"aliases":["Emberfall Aegis"]`) {
t.Fatalf("spell catalog prompt input = %#v, want canonical overlay name and recognition alias", catalogInput)
}
artifact := readProductionJSON[dnd.SpellList](t, filepath.Join(runRoot, "lanes", "spells.json"))
if len(artifact.SpellCasts) != 1 || artifact.SpellCasts[0].Spell != "Aegis of Emberfall" {

View File

@@ -92,7 +92,7 @@ func TestProductionSpellCatalogValidationRetries(t *testing.T) {
t.Fatalf("rejection = %#v, want exhausted unknown-spell rejection", rejection)
}
if len(output.Warnings) != 0 {
t.Fatalf("warnings = %#v, want no warnings from rejected attempts", output.Warnings)
t.Fatalf("warnings = %#v, want no emitted warnings from rejected attempts", output.Warnings)
}
return
}

View File

@@ -955,6 +955,9 @@ func (stateTestCodec) Encode(v stateTestArtifact) ([]byte, error) {
func (stateTestCodec) Decode([]byte) (stateTestArtifact, error) {
return stateTestArtifact{Value: "ok"}, nil
}
func (codec stateTestCodec) DecodeCandidate(content []byte) (stateTestArtifact, error) {
return codec.Decode(content)
}
type stateTestExtractor struct{ harness *stateTestHarness }

View File

@@ -427,6 +427,10 @@ func (effectiveCodec) Decode(content []byte) (effectiveArtifact, error) {
return value, err
}
func (codec effectiveCodec) DecodeCandidate(content []byte) (effectiveArtifact, error) {
return codec.Decode(content)
}
type effectiveInput struct{ key string }
func (m effectiveInput) Key() string { return m.key }

View File

@@ -3,6 +3,7 @@ package config
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"sort"
@@ -349,6 +350,12 @@ func ParseFileConfigYAML(data []byte) (FileConfig, error) {
if err := decoder.Decode(&fileCfg); err != nil {
return FileConfig{}, fmt.Errorf("decode yaml: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err == nil {
return FileConfig{}, fmt.Errorf("config must contain exactly one YAML document")
} else if err != io.EOF {
return FileConfig{}, fmt.Errorf("decode trailing yaml document: %w", err)
}
return fileCfg, nil
}

View File

@@ -50,6 +50,28 @@ func TestFileConfigMinimalVersion4AppliesOverDefaults(t *testing.T) {
}
}
func TestParseFileConfigYAMLRejectsAdditionalDocuments(t *testing.T) {
tests := []struct {
name string
source string
wantErr bool
}{
{name: "trailing whitespace", source: "version: 4\n\n \t", wantErr: false},
{name: "trailing comment", source: "version: 4\n# trailing comment\n", wantErr: false},
{name: "second valid document", source: "version: 4\n---\nversion: 4\n", wantErr: true},
{name: "second empty document", source: "version: 4\n---\n", wantErr: true},
{name: "second malformed document", source: "version: 4\n---\nversion: [\n", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(tt.source))
if (err != nil) != tt.wantErr {
t.Fatalf("ParseFileConfigYAML() error = %v, want error=%t", err, tt.wantErr)
}
})
}
}
func TestFilePipelineLLMProfileIsPresenceAwareAndDetached(t *testing.T) {
const pipelineYAML = `version: 4
pipelines:

View File

@@ -10,6 +10,38 @@ import (
"strings"
)
// EncodePathComponent returns a filesystem-safe, injective representation of
// one logical path component.
func EncodePathComponent(value string) string {
if value == "" {
return "%"
}
const hexadecimal = "0123456789ABCDEF"
var out strings.Builder
for index := 0; index < len(value); index++ {
byteValue := value[index]
switch {
case byteValue >= 'a' && byteValue <= 'z', byteValue >= 'A' && byteValue <= 'Z', byteValue >= '0' && byteValue <= '9', byteValue == '-', byteValue == '_':
out.WriteByte(byteValue)
case byteValue == '.' && safePathDot(value, index):
out.WriteByte(byteValue)
default:
out.WriteByte('%')
out.WriteByte(hexadecimal[byteValue>>4])
out.WriteByte(hexadecimal[byteValue&0x0f])
}
}
return out.String()
}
func safePathDot(value string, index int) bool {
if value == "." || value == ".." {
return false
}
return (index == 0 || value[index-1] != '.') && (index+1 == len(value) || value[index+1] != '.')
}
func SafePath(root, name string) (string, error) {
root = strings.TrimSpace(root)
if root == "" {

View File

@@ -15,6 +15,39 @@ func TestSafePathRejectsUnsafeNames(t *testing.T) {
}
}
func TestEncodePathComponentIsInjectiveAndSafe(t *testing.T) {
root := t.TempDir()
seen := make(map[string]string)
for _, test := range []struct {
value string
want string
}{
{value: "", want: "%"},
{value: ".", want: "%2E"},
{value: "..", want: "%2E%2E"},
{value: "_", want: "_"},
{value: "a..b", want: "a%2E%2Eb"},
{value: "safe.identifier-9", want: "safe.identifier-9"},
{value: "left/right", want: "left%2Fright"},
{value: "%", want: "%25"},
{value: "~", want: "%7E"},
{value: " a ", want: "%20a%20"},
{value: "é", want: "%C3%A9"},
} {
got := EncodePathComponent(test.value)
if got != test.want {
t.Errorf("EncodePathComponent(%q) = %q, want %q", test.value, got, test.want)
}
if previous, ok := seen[got]; ok {
t.Errorf("EncodePathComponent(%q) = %q, collides with %q", test.value, got, previous)
}
seen[got] = test.value
if _, err := SafePath(root, "components/"+got); err != nil {
t.Errorf("EncodePathComponent(%q) produced unsafe component %q: %v", test.value, got, err)
}
}
}
func TestWriteBytesIsAtomicAndUsesRequestedModes(t *testing.T) {
root := t.TempDir()
if err := WriteBytes(root, "nested/value", []byte("value"), 0o700, 0o600); err != nil {

View File

@@ -475,36 +475,9 @@ func laneManifestPath(stage string, stepID string, laneID string) string {
func lanePayloadPath(stage string, stepID string, laneID string, file string) string {
if strings.TrimSpace(stepID) == "" {
return path.Join(stage, checkpointPathComponent(laneID), file)
return path.Join(stage, fileio.EncodePathComponent(laneID), file)
}
return path.Join(stage, checkpointPathComponent(stepID), checkpointPathComponent(laneID), file)
}
func checkpointPathComponent(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "_"
}
var b strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-' || r == '_' || r == '.':
b.WriteRune(r)
default:
b.WriteString(fmt.Sprintf("~%x", r))
}
}
out := b.String()
if out == "." || out == ".." || strings.Contains(out, "..") {
return "_"
}
return out
return path.Join(stage, fileio.EncodePathComponent(stepID), fileio.EncodePathComponent(laneID), file)
}
func contentDigest(content []byte) string {

View File

@@ -5,6 +5,8 @@ import (
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
@@ -66,6 +68,56 @@ func TestStepAwareRecorderAndLoaderIsolateLaneState(t *testing.T) {
}
}
func TestStepAwareCheckpointPreservesDistinctDotIdentities(t *testing.T) {
root := t.TempDir()
identity := testIdentity(t)
recorder, err := NewFilesystemRecorder(root, identity)
if err != nil {
t.Fatal(err)
}
stepRecorder := recorder.(pipeline.StepCheckpointRecorder)
for _, test := range []struct {
stepID string
content string
}{
{stepID: ".", content: `{"identity":"dot"}`},
{stepID: "..", content: `{"identity":"dot-dot"}`},
} {
artifact := pipeline.CheckpointArtifact{
LaneID: "lane", ModuleKey: "normalize-module", SourceID: "source", ChunkID: "chunk", ChunkRef: source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}, SchemaDigest: "sha256:schema",
Artifact: contracts.SerializedArtifact{Kind: "kind", Schema: contracts.ArtifactSchema{ID: "schema", Name: "Schema", Version: "1"}, MediaType: "application/json", Content: []byte(test.content)},
}
if err := stepRecorder.NormalizeSucceededForStep(test.stepID, "lane", "normalize-module", nil, artifact, nil); err != nil {
t.Fatalf("record %q: %v", test.stepID, err)
}
}
loader, err := NewFilesystemLoader(root, identity)
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
stepID string
content string
path string
}{
{stepID: ".", content: `{"identity":"dot"}`, path: "%2E"},
{stepID: "..", content: `{"identity":"dot-dot"}`, path: "%2E%2E"},
} {
loaded, decision := loader.AcceptedNormalize(test.stepID, "lane", "normalize-module")
if !decision.Reused || string(loaded.Output.Artifact.Content) != test.content {
t.Errorf("load %q = %#v, decision=%#v", test.stepID, loaded, decision)
}
relative, err := identity.RelativePath()
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, relative, "normalize", test.path, "lane", "manifest.json")); err != nil {
t.Errorf("checkpoint for %q: %v", test.stepID, err)
}
}
}
func TestCheckpointSchemaCompatibilityIdentifiers(t *testing.T) {
if WorkspaceSchemaVersion != "notarius.workspace.v3" || WorkspaceSchemaVersionV2 != "notarius.workspace.v2" || WorkspaceSchemaVersionV1 != "notarius.workspace.v1" {
t.Fatal("checkpoint schema identifiers are incorrect")

View File

@@ -102,11 +102,11 @@ func Build(request BuildRequest) (ChunkMap, error) {
Annotations: source.CloneChunkAnnotations(chunk.Annotations),
}
}
canonical, err := canonicalize(value)
canonical, err := canonicalizeOwned(value)
if err != nil {
return ChunkMap{}, fmt.Errorf("validate chunk map: %w", err)
}
return clone(canonical), nil
return canonical, nil
}
// Serialize builds and encodes the framework-owned serialized artifact.
@@ -132,7 +132,7 @@ func (c *Codec) Encode(value ChunkMap) ([]byte, error) {
if _, err := c.schemaBytes(); err != nil {
return nil, err
}
canonical, err := canonicalize(clone(value))
canonical, err := canonicalize(value)
if err != nil {
return nil, fmt.Errorf("encode source chunk map: %w", err)
}
@@ -163,11 +163,11 @@ func (c *Codec) Decode(content []byte) (ChunkMap, error) {
if err := decoder.Decode(&trailing); err != io.EOF {
return ChunkMap{}, fmt.Errorf("decode source chunk map: multiple JSON values")
}
canonical, err := canonicalize(value)
canonical, err := canonicalizeOwned(value)
if err != nil {
return ChunkMap{}, fmt.Errorf("decode source chunk map: %w", err)
}
return clone(canonical), nil
return canonical, nil
}
func (c *Codec) schemaBytes() ([]byte, error) {
@@ -241,6 +241,10 @@ func hasRequiredFields(required []string) bool {
}
func canonicalize(value ChunkMap) (ChunkMap, error) {
return canonicalizeOwned(clone(value))
}
func canonicalizeOwned(value ChunkMap) (ChunkMap, error) {
if err := requireIdentity("source_id", value.SourceID); err != nil {
return ChunkMap{}, err
}

View File

@@ -56,6 +56,15 @@ func TestCodecRoundTripsValidFixture(t *testing.T) {
if !bytes.Equal(encoded, bytes.TrimSpace(fixture)) {
t.Fatalf("fixture does not use canonical encoding\nwant: %s\n got: %s", fixture, encoded)
}
value.PlanAnnotations["test/chunker"][0] = '['
value.Chunks[0].Annotations["test/chunker"][0] = '['
decoded, err := codec.Decode(encoded)
if err != nil {
t.Fatalf("Decode(encoded) after mutation error = %v", err)
}
if string(decoded.PlanAnnotations["test/chunker"]) != `{"label":"fixture"}` || string(decoded.Chunks[0].Annotations["test/chunker"]) != `{"category":"sample"}` {
t.Fatalf("Decode() reused mutable chunk-map storage: %#v", decoded)
}
}
func TestBuildCanonicalizesAnnotationFormatting(t *testing.T) {

View File

@@ -50,6 +50,13 @@ type ArtifactCodec[T any] interface {
Decode([]byte) (T, error)
}
// CandidateArtifactCodec extends an artifact codec with strict representation
// decoding for values that have not yet passed semantic validation.
type CandidateArtifactCodec[T any] interface {
ArtifactCodec[T]
DecodeCandidate([]byte) (T, error)
}
// DigestArtifactSchema returns the SHA-256 digest of the exact JSON Schema
// bytes. Schema formatting is therefore part of the registered identity.
func DigestArtifactSchema(schema ArtifactSchema) string {

View File

@@ -2,24 +2,8 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.source.evidence_context",
"title": "notarius_source_evidence_context_v1",
"type": "object",
"additionalProperties": false,
"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"}
}
},
"type": "array",
"items": {"$ref": "#/$defs/unit"},
"$defs": {
"source_ref": {
"type": "object",
@@ -42,25 +26,6 @@
"ref": {"$ref": "#/$defs/source_ref"},
"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 (
"fmt"
"sort"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
type contribution struct {
laneID string
ref source.SourceRef
type expandedRange struct {
startPos int
endPos int
}
type expandedRange struct {
startPos int
endPos int
contributions []contribution
}
// Build validates accepted direct references, expands them by source-document
// position, and returns their deterministic context union.
// Build validates projected source references, expands them by source-document
// position, and returns their ordered union as an owned source-unit excerpt.
func Build(request BuildRequest) (Document, error) {
if request.WindowUnits < 0 {
return Document{}, fmt.Errorf("window_units must not be negative")
}
lanes, err := normalizeSelectedLanes(request.SelectedLanes)
if err != nil {
return Document{}, err
return nil, fmt.Errorf("window_units must not be negative")
}
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)
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 {
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)
seen := make(map[evidenceKey]struct{})
contributions := make([]contribution, 0)
for laneIndex, laneEvidence := range request.LaneEvidence {
laneID := strings.TrimSpace(laneEvidence.LaneID)
if _, ok := selected[laneID]; !ok {
return Document{}, fmt.Errorf("lane evidence[%d] lane %q is not selected", laneIndex, laneID)
ranges := make([]expandedRange, 0, len(request.SourceRefs))
for refIndex, ref := range request.SourceRefs {
if err := index.ValidateRef(ref); err != nil {
return nil, fmt.Errorf("source reference[%d]: %w", refIndex, err)
}
for refIndex, ref := range laneEvidence.SourceRefs {
if err := index.ValidateRef(ref); err != nil {
return Document{}, fmt.Errorf("lane %q source reference[%d]: %w", laneID, refIndex, err)
startPos, _ := index.Position(ref.StartUnitID)
endPos, _ := index.Position(ref.EndUnitID)
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}
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)})
document = append(document, unit)
}
}
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 := canonicalize(document)
if err != nil {
return Document{}, fmt.Errorf("validate evidence context: %w", err)
}
return clone(canonical)
}
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
return document, nil
}
func expandStart(position, window int) int {
@@ -132,65 +76,25 @@ func expandEnd(position, length, window int) int {
return position + window
}
func lessContribution(left, right contribution) bool {
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 {
func mergeRanges(values []expandedRange) []expandedRange {
if len(values) == 0 {
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 {
if len(ranges) == 0 || value.startPos > ranges[len(ranges)-1].endPos+1 {
ranges = append(ranges, expandedRange{startPos: value.startPos, endPos: value.endPos, contributions: []contribution{value}})
if len(merged) == 0 || value.startPos > merged[len(merged)-1].endPos+1 {
merged = append(merged, value)
continue
}
current := &ranges[len(ranges)-1]
if value.endPos > current.endPos {
current.endPos = value.endPos
if value.endPos > merged[len(merged)-1].endPos {
merged[len(merged)-1].endPos = value.endPos
}
current.contributions = append(current.contributions, value)
}
return ranges
}
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
return merged
}

View File

@@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"io"
"regexp"
"strings"
"sync"
@@ -18,8 +17,6 @@ import (
//go:embed assets/schemas/source_evidence_context.v1.json
var schemaAssets embed.FS
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
var (
loadSchemaOnce sync.Once
loadedSchema []byte
@@ -78,26 +75,26 @@ func (c *Codec) Encode(value Document) ([]byte, error) {
func (c *Codec) Decode(content []byte) (Document, error) {
if _, err := c.schemaBytes(); err != nil {
return Document{}, err
return nil, err
}
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.DisallowUnknownFields()
var value Document
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
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 := canonicalize(value)
canonical, err := canonicalizeOwned(value)
if err != nil {
return Document{}, fmt.Errorf("decode evidence context: %w", err)
return nil, fmt.Errorf("decode evidence context: %w", err)
}
return clone(canonical)
return canonical, nil
}
func (c *Codec) schemaBytes() ([]byte, error) {
@@ -115,17 +112,16 @@ func loadAndCompileSchema() {
return
}
var identity struct {
ID string `json:"$id"`
Title string `json:"title"`
Type string `json:"type"`
Required []string `json:"required"`
ID string `json:"$id"`
Title string `json:"title"`
Type string `json:"type"`
}
if err := json.Unmarshal(raw, &identity); err != nil {
loadSchemaErr = fmt.Errorf("decode source evidence context schema: %w", err)
return
}
if identity.ID != SchemaID || identity.Title != SchemaName || identity.Type != "object" || !hasRequiredFields(identity.Required) {
loadSchemaErr = fmt.Errorf("source evidence context schema identity or required fields are invalid")
if identity.ID != SchemaID || identity.Title != SchemaName || identity.Type != "array" {
loadSchemaErr = fmt.Errorf("source evidence context schema identity is invalid")
return
}
schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
@@ -158,165 +154,65 @@ func validateSchemaInstance(content []byte) error {
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) {
owned, err := clone(value)
if err != nil {
return Document{}, err
return nil, err
}
value = owned
if err := requireIdentity("source_id", value.SourceID); err != nil {
return Document{}, err
}
if !digestPattern.MatchString(value.SourceDigest) {
return Document{}, fmt.Errorf("source_digest must be a sha256 digest")
}
if value.WindowUnits < 0 {
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
return canonicalizeOwned(owned)
}
func validateSelectedLanes(lanes []string) error {
if len(lanes) == 0 {
return fmt.Errorf("selected_lanes must not be empty")
func canonicalizeOwned(value Document) (Document, error) {
if value == nil {
return nil, fmt.Errorf("document must be a JSON array")
}
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, err := cloneSourceUnit(value.Units[unitIndex])
if err != nil {
return Context{}, fmt.Errorf("%s.units[%d]: %w", prefix, unitIndex, err)
}
seenUnitIDs := make(map[int]struct{}, len(value))
sourceID := ""
for unitIndex := range value {
unit := value[unitIndex]
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 {
return Context{}, err
if err := validateUnitRef(unit, unitIndex); err != nil {
return nil, err
}
if unit.Ref.StartUnitID != unit.ID || unit.Ref.EndUnitID != unit.ID {
return Context{}, fmt.Errorf("%s.units[%d].ref must identify unit id %d", prefix, unitIndex, unit.ID)
if sourceID == "" {
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 {
return Context{}, fmt.Errorf("%s.units contains duplicate unit id %d", prefix, 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{}{}
value.Units[unitIndex] = unit
}
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)
if _, exists := seenUnitIDs[unit.ID]; exists {
return nil, fmt.Errorf("units contains duplicate unit id %d", unit.ID)
}
seenUnitIDs[unit.ID] = struct{}{}
}
return value, nil
}
func validateRefIdentity(sourceID string, ref source.SourceRef, field string) error {
if ref.SourceID != sourceID {
return fmt.Errorf("%s.source_id does not match source_id", field)
func validateUnitRef(unit source.SourceUnit, unitIndex int) error {
prefix := fmt.Sprintf("units[%d].ref", unitIndex)
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 {
return fmt.Errorf("%s endpoints must be positive", field)
}
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)
if unit.Ref.StartUnitID != unit.ID || unit.Ref.EndUnitID != unit.ID {
return fmt.Errorf("%s must identify unit id %d", prefix, unit.ID)
}
return nil
}
func clone(value Document) (Document, error) {
value.SelectedLanes = append([]string(nil), value.SelectedLanes...)
if value.Contexts == 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
if value == nil {
return nil, nil
}
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) {

View File

@@ -2,7 +2,6 @@ package evidencecontext
import (
"bytes"
"encoding/json"
"math"
"os"
"reflect"
@@ -12,126 +11,57 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
func TestBuildExpandsAndMergesEvidenceByDocumentPosition(t *testing.T) {
func TestBuildSelectsExpandedSourceUnitUnion(t *testing.T) {
for _, test := range []struct {
name string
window int
evidence []LaneEvidence
wantUnits [][]int
wantRefs [][]EvidenceRef
name string
window int
refs []source.SourceRef
wantIDs []int
}{
{
name: "zero window",
evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}},
wantUnits: [][]int{{3}},
wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(3, 3)}}},
},
{
name: "non monotonic ids use positions and clip boundaries",
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)}}},
},
{name: "zero window", refs: []source.SourceRef{ref(3, 3)}, wantIDs: []int{3}},
{name: "multi unit citation includes complete range", refs: []source.SourceRef{ref(3, 7)}, wantIDs: []int{3, 30, 7}},
{name: "non monotonic IDs use document positions", window: 1, refs: []source.SourceRef{ref(3, 3)}, wantIDs: []int{10, 3, 30}},
{name: "boundary clamping", window: 1, refs: []source.SourceRef{ref(10, 10), ref(50, 50)}, wantIDs: []int{10, 3, 7, 50}},
{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: "no references returns an initialized empty document", wantIDs: []int{}},
} {
t.Run(test.name, func(t *testing.T) {
document := testDocument(t)
got, err := Build(BuildRequest{Source: document, WindowUnits: test.window, SelectedLanes: []string{"spells", "npcs"}, LaneEvidence: test.evidence})
got, err := Build(BuildRequest{Source: testDocument(t), WindowUnits: test.window, SourceRefs: test.refs})
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if want := []string{"npcs", "spells"}; !reflect.DeepEqual(got.SelectedLanes, want) {
t.Fatalf("SelectedLanes = %#v, want %#v", got.SelectedLanes, want)
if got == nil {
t.Fatal("Build() returned a nil document")
}
if got.WindowUnits != test.window || got.SourceID != document.ID || got.SourceDigest != document.Digest {
t.Fatalf("Build() identity = %#v, want source and window identity", got)
}
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)
if actual := unitIDs(got); !reflect.DeepEqual(actual, test.wantIDs) {
t.Fatalf("unit IDs = %#v, want %#v", actual, test.wantIDs)
}
})
}
}
func TestBuildIsStableAndOwnsSourceAndInputs(t *testing.T) {
func TestBuildCopiesSelectedUnitsAndMetadata(t *testing.T) {
document := testDocument(t)
refs := []source.SourceRef{ref(30, 30), 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)
first, err := Build(BuildRequest{Source: document, WindowUnits: 1, SourceRefs: []source.SourceRef{ref(3, 3)}})
if err != nil {
t.Fatal(err)
}
secondRequest := request
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)
second, err := Build(BuildRequest{Source: document, WindowUnits: 1, SourceRefs: []source.SourceRef{ref(3, 3)}})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(first, second) {
t.Fatalf("Build() order differs:\nfirst: %#v\nsecond: %#v", first, second)
if !reflect.DeepEqual(first[0], document.Units[0]) {
t.Fatalf("first unit = %#v, want unchanged source unit %#v", first[0], document.Units[0])
}
first.SelectedLanes[0] = "changed"
first.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] = "changed"
first[0].Metadata["nested"].(map[string]any)["value"] = "changed"
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"
if second.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] != "original" {
t.Fatal("Build() retained metadata aliases to source document")
}
refs[0].StartUnitID = 999
if !containsEvidenceRef(second.Contexts[0].EvidenceRefs, ref(30, 30)) {
t.Fatal("Build() retained source-reference input aliases")
if second[0].Metadata["nested"].(map[string]any)["value"] != "original" {
t.Fatal("Build() retained metadata aliases to the source document")
}
}
@@ -142,18 +72,11 @@ func TestBuildRejectsInvalidInputs(t *testing.T) {
want string
}{
{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: "invalid reference", mutate: func(request *BuildRequest) {
request.LaneEvidence = []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(99, 99)}}}
}, want: "source reference[0]"},
{name: "invalid reference", mutate: func(request *BuildRequest) { request.SourceRefs = []source.SourceRef{ref(99, 99)} }, want: "source reference[0]"},
} {
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)
if _, err := Build(request); err == nil || !strings.Contains(err.Error(), 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")
if err != nil {
t.Fatal(err)
@@ -179,15 +102,16 @@ func TestCodecRoundTripsCompactFixtureAndOwnsDecodedValues(t *testing.T) {
if !bytes.Equal(encoded, bytes.TrimSpace(fixture)) {
t.Fatalf("fixture does not use canonical encoding\nwant: %s\n got: %s", fixture, encoded)
}
value.Contexts[0].Units[0].Text = "changed"
decoded, err := codec.Decode(fixture)
value[0].Text = "changed"
decoded, err := codec.Decode(encoded)
if err != nil {
t.Fatal(err)
}
if decoded.Contexts[0].Units[0].Text != "The party meets Rowan." {
t.Fatal("Decode() reused mutable document storage")
if decoded[0].Text != "The party meets Rowan." {
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 {
t.Fatal(err)
}
@@ -203,82 +127,52 @@ func TestCodecRoundTripsCompactFixtureAndOwnsDecodedValues(t *testing.T) {
if err != nil {
t.Fatal(err)
}
first.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] = "changed"
if second.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] != "original" {
first[0].Metadata["nested"].(map[string]any)["value"] = "changed"
if second[0].Metadata["nested"].(map[string]any)["value"] != "original" {
t.Fatal("Decode() returned metadata aliases")
}
}
func TestCodecRejectsInvalidDurableBoundaries(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)
}
func TestCodecRejectsInvalidDurablePayloads(t *testing.T) {
for _, test := range []struct {
name string
mutate func(*Document)
name string
content string
}{
{name: "unsorted lanes", mutate: func(value *Document) { value.SelectedLanes = []string{"z", "a"} }},
{name: "context range mismatch", mutate: func(value *Document) { value.Contexts[0].ContextRef.EndUnitID = 999 }},
{name: "mismatched evidence source", mutate: func(value *Document) { value.Contexts[0].EvidenceRefs[0].SourceRef.SourceID = "other" }},
{name: "invalid evidence range", mutate: func(value *Document) {
value.Contexts[0].EvidenceRefs[0].SourceRef.StartUnitID = 10
}},
{name: "duplicate context unit", mutate: func(value *Document) { value.Contexts = append(value.Contexts, value.Contexts[0]) }},
{name: "null", content: "null"},
{name: "wrapper object", content: `{"units":[]}`},
{name: "missing required unit field", content: `[{"id":1,"kind":"segment","ref":{"source_id":"session","start_unit_id":1,"end_unit_id":1}}]`},
{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}]`},
{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: "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) {
candidate, err := clone(value)
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 {
if _, err := New().Decode([]byte(test.content)); err == nil {
t.Fatal("Decode() error = nil, want strict payload rejection")
}
})
}
if _, err := New().Decode(append(content, []byte(" {}")...)); err == nil {
t.Fatal("Decode() error = nil, want trailing JSON rejection")
if _, err := New().Encode(nil); err == nil {
t.Fatal("Encode(nil) error = nil, want array rejection")
}
}
func TestSerializeUsesFixedArtifactIdentity(t *testing.T) {
artifact, err := Serialize(BuildRequest{Source: testDocument(t), SelectedLanes: []string{"npcs"}})
func TestSerializeUsesFixedArtifactIdentityAndEmptyArray(t *testing.T) {
artifact, err := Serialize(BuildRequest{Source: testDocument(t)})
if err != nil {
t.Fatal(err)
}
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)
}
if string(artifact.Content) != "[]" {
t.Fatalf("Serialize() content = %s, want []", artifact.Content)
}
decoded, err := New().Decode(artifact.Content)
if err != nil || len(decoded.Contexts) != 0 || decoded.Contexts == nil {
t.Fatalf("Decode(Serialize()) = %#v, %v; want explicit empty contexts", decoded, err)
if err != nil || decoded == nil || len(decoded) != 0 {
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}
}
func contextUnitIDs(contexts []Context) [][]int {
values := make([][]int, len(contexts))
for index, context := range contexts {
values[index] = make([]int, len(context.Units))
for unitIndex, unit := range context.Units {
values[index][unitIndex] = unit.ID
}
func unitIDs(units Document) []int {
values := make([]int, len(units))
for index, unit := range units {
values[index] = unit.ID
}
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"
)
// Document is the durable union of direct evidence and surrounding source
// context selected for one accepted source document.
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"`
}
// Document is the durable selected source-unit excerpt.
type Document []source.SourceUnit
type Context struct {
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.
// BuildRequest supplies accepted source material and projected source references.
type BuildRequest struct {
Source *source.SourceDocument
WindowUnits int
SelectedLanes []string
LaneEvidence []LaneEvidence
Source *source.SourceDocument
WindowUnits int
SourceRefs []source.SourceRef
}

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

@@ -1,17 +1,15 @@
package llm
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"path"
"sort"
"strings"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/readonlyfs"
"gitea.maximumdirect.net/eric/promptkit"
)
@@ -223,7 +221,7 @@ func newAssetSource(fsys fs.FS, root string) (AssetSource, error) {
}
func flattenAssetSources(sources []AssetSource) (fs.FS, error) {
out := assetMapFS{}
out := make(map[string][]byte)
for _, source := range sources {
if err := fs.WalkDir(source.FS, source.Root, func(name string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
@@ -247,13 +245,13 @@ func flattenAssetSources(sources []AssetSource) (fs.FS, error) {
if err != nil {
return err
}
out[rel] = append([]byte(nil), data...)
out[rel] = data
return nil
}); err != nil {
return nil, fmt.Errorf("walk asset root %s: %w", source.Root, err)
}
}
return out, nil
return readonlyfs.New(out)
}
func cleanAssetRoot(root string) (string, error) {
@@ -275,156 +273,3 @@ func cleanAssetPath(name string) (string, error) {
}
return cleaned, nil
}
type assetMapFS map[string][]byte
func (m assetMapFS) Open(name string) (fs.File, error) {
cleaned, err := cleanOpenPath(name)
if err != nil {
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
}
if data, ok := m[cleaned]; ok {
return &assetFile{
reader: bytes.NewReader(data),
info: assetFileInfo{name: path.Base(cleaned), size: int64(len(data))},
}, nil
}
entries := m.dirEntries(cleaned)
if entries != nil {
return &assetDir{name: path.Base(cleaned), entries: entries}, nil
}
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
func (m assetMapFS) ReadFile(name string) ([]byte, error) {
cleaned, err := cleanOpenPath(name)
if err != nil {
return nil, &fs.PathError{Op: "readfile", Path: name, Err: err}
}
data, ok := m[cleaned]
if !ok {
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrNotExist}
}
return append([]byte(nil), data...), nil
}
func (m assetMapFS) ReadDir(name string) ([]fs.DirEntry, error) {
cleaned, err := cleanOpenPath(name)
if err != nil {
return nil, &fs.PathError{Op: "readdir", Path: name, Err: err}
}
entries := m.dirEntries(cleaned)
if entries == nil {
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
}
return entries, nil
}
func (m assetMapFS) dirEntries(dir string) []fs.DirEntry {
children := map[string]assetDirEntry{}
prefix := ""
if dir != "." {
prefix = dir + "/"
}
for name, data := range m {
if !strings.HasPrefix(name, prefix) {
continue
}
rest := strings.TrimPrefix(name, prefix)
if rest == "" {
continue
}
childName, _, hasSlash := strings.Cut(rest, "/")
entry := assetDirEntry{name: childName, dir: hasSlash}
if !hasSlash {
entry.size = int64(len(data))
}
children[childName] = entry
}
if len(children) == 0 {
if dir == "." {
return []fs.DirEntry{}
}
return nil
}
names := make([]string, 0, len(children))
for name := range children {
names = append(names, name)
}
sort.Strings(names)
entries := make([]fs.DirEntry, 0, len(names))
for _, name := range names {
entries = append(entries, children[name])
}
return entries
}
func cleanOpenPath(name string) (string, error) {
if name == "." {
return ".", nil
}
return cleanAssetPath(name)
}
type assetFile struct {
reader *bytes.Reader
info assetFileInfo
}
func (f *assetFile) Stat() (fs.FileInfo, error) { return f.info, nil }
func (f *assetFile) Read(p []byte) (int, error) { return f.reader.Read(p) }
func (f *assetFile) Close() error { return nil }
type assetDir struct {
name string
offset int
entries []fs.DirEntry
}
func (d *assetDir) Stat() (fs.FileInfo, error) { return assetFileInfo{name: d.name, dir: true}, nil }
func (d *assetDir) Read([]byte) (int, error) { return 0, fmt.Errorf("cannot read directory") }
func (d *assetDir) Close() error { return nil }
func (d *assetDir) ReadDir(n int) ([]fs.DirEntry, error) {
if d.offset >= len(d.entries) {
return nil, io.EOF
}
end := len(d.entries)
if n > 0 && d.offset+n < end {
end = d.offset + n
}
out := append([]fs.DirEntry(nil), d.entries[d.offset:end]...)
d.offset = end
return out, nil
}
type assetDirEntry struct {
name string
dir bool
size int64
}
func (e assetDirEntry) Name() string { return e.name }
func (e assetDirEntry) IsDir() bool { return e.dir }
func (e assetDirEntry) Type() fs.FileMode { return e.InfoMode().Type() }
func (e assetDirEntry) Info() (fs.FileInfo, error) {
return assetFileInfo{name: e.name, dir: e.dir, size: e.size}, nil
}
func (e assetDirEntry) InfoMode() fs.FileMode {
if e.dir {
return fs.ModeDir | 0o555
}
return 0o444
}
type assetFileInfo struct {
name string
dir bool
size int64
}
func (i assetFileInfo) Name() string { return i.name }
func (i assetFileInfo) Size() int64 { return i.size }
func (i assetFileInfo) Mode() fs.FileMode { return assetDirEntry{dir: i.dir}.InfoMode() }
func (i assetFileInfo) ModTime() time.Time { return time.Time{} }
func (i assetFileInfo) IsDir() bool { return i.dir }
func (i assetFileInfo) Sys() any { return nil }

View File

@@ -142,7 +142,7 @@ func (c *PromptKitClient) CompleteStructured(ctx context.Context, req contracts.
if ctxErr := ctx.Err(); ctxErr != nil {
return contracts.StructuredCompletionResponse{}, ctxErr
}
return contracts.StructuredCompletionResponse{}, fmt.Errorf("prepare PromptKit prompt %q: %w", promptID, redactPromptKitError(err))
return contracts.StructuredCompletionResponse{}, fmt.Errorf("prepare PromptKit prompt %q: %v", promptID, redactPromptKitError(err))
}
defer prepared.Discard()
preparedDetails := prepared.Details()
@@ -169,7 +169,7 @@ func (c *PromptKitClient) CompleteStructured(ctx context.Context, req contracts.
redactPromptKitError(err),
)
}
return contracts.StructuredCompletionResponse{}, fmt.Errorf("run PromptKit prompt %q: %w", promptID, redactPromptKitError(err))
return contracts.StructuredCompletionResponse{}, fmt.Errorf("run PromptKit prompt %q: %v", promptID, redactPromptKitError(err))
}
if result == nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("run PromptKit prompt %q: %w: empty result", promptID, contracts.ErrInvalidStructuredOutput)
@@ -427,17 +427,13 @@ func redactPromptKitError(err error) error {
if err == nil {
return nil
}
return redactedProviderError{err: err}
return sanitizedProviderDiagnostic{message: bearerTokenPattern.ReplaceAllString(err.Error(), "Bearer "+secretReplacement)}
}
type redactedProviderError struct {
err error
type sanitizedProviderDiagnostic struct {
message string
}
func (e redactedProviderError) Error() string {
return bearerTokenPattern.ReplaceAllString(e.err.Error(), "Bearer "+secretReplacement)
}
func (e redactedProviderError) Unwrap() error {
return e.err
func (e sanitizedProviderDiagnostic) Error() string {
return e.message
}

View File

@@ -741,6 +741,11 @@ model: local-model
!strings.Contains(err.Error(), promptkit.BackendLocal) {
t.Fatalf("CompleteStructured() without registration error = %v, want preparation failure with local backend context", err)
}
for _, sentinel := range []error{promptkit.ErrProfileLoad, promptkit.ErrInvalidRequest, promptkit.ErrPromptNotFound} {
if errors.Is(err, sentinel) {
t.Fatalf("preparation failure exposes PromptKit sentinel %v: %v", sentinel, err)
}
}
if providerCalls.Load() != 1 {
t.Fatalf("provider calls after missing-registration failure = %d, want 1", providerCalls.Load())
}
@@ -814,7 +819,8 @@ func TestPromptKitClientDecodeFailureReturnsRawResponse(t *testing.T) {
}
func TestPromptKitClientProviderFailureIncludesContextAndRedactsBearerToken(t *testing.T) {
client := newTestPromptKitClient(t, &fakePromptKitLLM{err: errors.New("provider failed with Bearer secret-token")})
providerErr := &credentialBearingProviderError{}
client := newTestPromptKitClient(t, &fakePromptKitLLM{err: providerErr})
var out map[string]any
resp, err := client.CompleteStructured(context.Background(), contracts.StructuredCompletionRequest{
@@ -836,6 +842,21 @@ func TestPromptKitClientProviderFailureIncludesContextAndRedactsBearerToken(t *t
if strings.Contains(err.Error(), "secret-token") || !strings.Contains(err.Error(), "Bearer [REDACTED]") {
t.Fatalf("error = %q, want redacted bearer token", err.Error())
}
for current := err; current != nil; current = errors.Unwrap(current) {
if strings.Contains(current.Error(), "secret-token") {
t.Fatalf("error chain exposes credential: %v", err)
}
}
if errors.Unwrap(err) != nil {
t.Fatalf("provider failure must not expose a wrapped diagnostic: %v", err)
}
var recoveredProviderErr *credentialBearingProviderError
if errors.As(err, &recoveredProviderErr) {
t.Fatalf("provider error escaped the adapter: %v", err)
}
if errors.Is(err, promptkit.ErrLLMGenerate) {
t.Fatalf("provider failure exposes PromptKit generation sentinel: %v", err)
}
if resp.Debug != nil {
t.Fatalf("debug material = %#v, want none for provider failure without result", resp.Debug)
}
@@ -1214,6 +1235,12 @@ type fakePromptKitLLM struct {
maxInFlight int32
}
type credentialBearingProviderError struct{}
func (*credentialBearingProviderError) Error() string {
return "provider failed with Bearer secret-token"
}
func (f *fakePromptKitLLM) Generate(ctx context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
f.mu.Lock()
f.last = req

View File

@@ -83,6 +83,9 @@ func (s *Scheduler) Run(ctx context.Context, fn func(context.Context) error) err
return err
}
defer release()
if err := ctx.Err(); err != nil {
return err
}
return fn(ctx)
}

View File

@@ -133,6 +133,79 @@ func TestSchedulerRunReleasesPermitAfterError(t *testing.T) {
}
}
type cancelAfterAdmissionContext struct {
done chan struct{}
checks atomic.Int32
once sync.Once
}
func newCancelAfterAdmissionContext() *cancelAfterAdmissionContext {
return &cancelAfterAdmissionContext{done: make(chan struct{})}
}
func (*cancelAfterAdmissionContext) Deadline() (time.Time, bool) { return time.Time{}, false }
func (c *cancelAfterAdmissionContext) Done() <-chan struct{} { return c.done }
func (c *cancelAfterAdmissionContext) Err() error {
if c.checks.Add(1) == 1 {
return nil
}
c.once.Do(func() { close(c.done) })
return context.Canceled
}
func (*cancelAfterAdmissionContext) Value(any) any { return nil }
func TestSchedulerDoesNotDispatchCanceledAdmissionAndReleasesNextWaiter(t *testing.T) {
s, err := NewScheduler(1)
if err != nil {
t.Fatalf("NewScheduler: %v", err)
}
hold, err := s.Acquire(context.Background())
if err != nil {
t.Fatalf("Acquire: %v", err)
}
defer hold()
ctx := newCancelAfterAdmissionContext()
var canceledCalls atomic.Int32
canceledErr := make(chan error, 1)
go func() {
canceledErr <- s.Run(ctx, func(context.Context) error {
canceledCalls.Add(1)
return nil
})
}()
waitForQueueDepth(t, s, 1)
nextStarted := make(chan struct{})
nextErr := make(chan error, 1)
go func() {
nextErr <- s.Run(context.Background(), func(context.Context) error {
close(nextStarted)
return nil
})
}()
waitForQueueDepth(t, s, 2)
hold()
if err := <-canceledErr; !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context.Canceled", err)
}
if got := canceledCalls.Load(); got != 0 {
t.Fatalf("canceled callback calls = %d, want none", got)
}
select {
case <-nextStarted:
case <-time.After(time.Second):
t.Fatalf("timed out waiting for next FIFO waiter")
}
if err := <-nextErr; err != nil {
t.Fatalf("next Run() error = %v", err)
}
}
func waitForAtomicAtLeast(t *testing.T, value *int32, want int32) {
t.Helper()
deadline := time.Now().Add(time.Second)

View File

@@ -69,6 +69,7 @@ type artifactCodecEntry struct {
encodeCandidate func(any) ([]byte, error)
metadata func(any) (map[string]any, error)
decode func([]byte) (any, error)
decodeCandidate func([]byte) (any, error)
}
func NewArtifactCodecRegistry() *ArtifactCodecRegistry {
@@ -77,7 +78,7 @@ func NewArtifactCodecRegistry() *ArtifactCodecRegistry {
// RegisterArtifactCodec registers one codec for T. The concrete type is kept
// private and checked at every erased encode boundary.
func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contracts.ArtifactCodec[T]) error {
func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contracts.CandidateArtifactCodec[T]) error {
if registry == nil {
return fmt.Errorf("artifact codec registry must not be nil")
}
@@ -119,6 +120,13 @@ func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contrac
}
return decoded, nil
},
decodeCandidate: func(content []byte) (any, error) {
decoded, err := codec.DecodeCandidate(append([]byte(nil), content...))
if err != nil {
return nil, &ArtifactCodecOperationError{Operation: "decode candidate", Kind: spec.Kind, Err: err}
}
return decoded, nil
},
}
entry.encodeCandidate = func(value any) ([]byte, error) {
typed, err := exactTypedValue[T]("encode candidate artifact", value)

View File

@@ -14,7 +14,13 @@ import (
)
type codecNotes struct {
Items []string `json:"items"`
Items []string `json:"items"`
Labels map[string]string `json:"labels,omitempty"`
Details *codecNoteDetails `json:"details,omitempty"`
}
type codecNoteDetails struct {
Name string `json:"name"`
}
type codecScore struct {
@@ -24,12 +30,13 @@ type codecScore struct {
type codecNotesAlias codecNotes
type testArtifactCodec[T any] struct {
kind contracts.ArtifactKind
schema contracts.ArtifactSchema
mediaType string
encodeFunc func(T) ([]byte, error)
candidateFunc func(T) ([]byte, error)
decodeFunc func([]byte) (T, error)
kind contracts.ArtifactKind
schema contracts.ArtifactSchema
mediaType string
encodeFunc func(T) ([]byte, error)
candidateFunc func(T) ([]byte, error)
decodeFunc func([]byte) (T, error)
candidateDecodeFunc func([]byte) (T, error)
}
func (c testArtifactCodec[T]) Kind() contracts.ArtifactKind { return c.kind }
@@ -43,8 +50,15 @@ func (c testArtifactCodec[T]) EncodeCandidate(value T) ([]byte, error) {
}
func (c testArtifactCodec[T]) Encode(value T) ([]byte, error) { return c.encodeFunc(value) }
func (c testArtifactCodec[T]) Decode(content []byte) (T, error) { return c.decodeFunc(content) }
func (c testArtifactCodec[T]) DecodeCandidate(content []byte) (T, error) {
if c.candidateDecodeFunc != nil {
return c.candidateDecodeFunc(content)
}
return c.decodeFunc(content)
}
var _ contracts.ArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{}
var _ contracts.CandidateArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{}
func TestArtifactCodecRegistryStoresHeterogeneousExactTypes(t *testing.T) {
registry := NewArtifactCodecRegistry()
@@ -138,6 +152,44 @@ func TestArtifactCodecRegistryKeepsCandidateAndFinalEncodingDistinct(t *testing.
}
}
func TestArtifactCodecRegistryKeepsCandidateAndFinalDecodingDistinct(t *testing.T) {
candidateCalls, finalCalls := 0, 0
codec := notesCodec()
codec.candidateDecodeFunc = func([]byte) (codecNotes, error) {
candidateCalls++
return codecNotes{Items: []string{"candidate"}}, nil
}
codec.decodeFunc = func([]byte) (codecNotes, error) {
finalCalls++
return codecNotes{Items: []string{"final"}}, nil
}
registry := NewArtifactCodecRegistry()
if err := RegisterArtifactCodec(registry, codec); err != nil {
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
}
entry, _, err := registry.entry(codec.kind)
if err != nil {
t.Fatalf("entry() error = %v, want nil", err)
}
candidate, err := entry.decodeCandidate([]byte(`{"items":["one"]}`))
if err != nil {
t.Fatalf("candidate decode error = %v", err)
}
candidateWant := codecNotes{Items: []string{"candidate"}}
if !reflect.DeepEqual(candidate, candidateWant) || candidateCalls != 1 || finalCalls != 0 {
t.Fatalf("candidate decode = %#v, calls = candidate %d, final %d", candidate, candidateCalls, finalCalls)
}
decoded, err := entry.decode([]byte(`{"items":["one"]}`))
if err != nil {
t.Fatalf("final decode error = %v", err)
}
finalWant := codecNotes{Items: []string{"final"}}
if !reflect.DeepEqual(decoded, finalWant) || candidateCalls != 1 || finalCalls != 1 {
t.Fatalf("final decode = %#v, calls = candidate %d, final %d", decoded, candidateCalls, finalCalls)
}
}
func TestArtifactCodecRegistryStoresValidatedSchemaMetadata(t *testing.T) {
registry := NewArtifactCodecRegistry()
codec := notesCodec()
@@ -164,7 +216,7 @@ func TestArtifactCodecRegistryStoresValidatedSchemaMetadata(t *testing.T) {
spec.Schema.JSONSchema[0] = '['
again, _ := registry.Spec("test/notes")
if string(again.Schema.JSONSchema) != `{"additionalProperties":false,"properties":{"items":{"items":{"type":"string"},"type":"array"}},"required":["items"],"type":"object"}` {
if string(again.Schema.JSONSchema) != `{"additionalProperties":false,"properties":{"details":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"},"items":{"items":{"type":"string"},"type":"array"},"labels":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["items"],"type":"object"}` {
t.Fatalf("stored JSON Schema changed through Spec result: %q", again.Schema.JSONSchema)
}
}
@@ -267,6 +319,28 @@ func TestArtifactCodecRegistryWrapsEncodeFailure(t *testing.T) {
}
}
func TestArtifactCodecRegistryWrapsCandidateDecodeFailure(t *testing.T) {
cause := errors.New("cannot decode candidate notes")
codec := notesCodec()
codec.candidateDecodeFunc = func([]byte) (codecNotes, error) {
return codecNotes{}, cause
}
registry := NewArtifactCodecRegistry()
if err := RegisterArtifactCodec(registry, codec); err != nil {
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
}
entry, _, err := registry.entry(codec.kind)
if err != nil {
t.Fatalf("entry() error = %v, want nil", err)
}
_, err = entry.decodeCandidate([]byte(`{"items":["one"]}`))
var operationErr *ArtifactCodecOperationError
if !errors.As(err, &operationErr) || operationErr.Operation != "decode candidate" || !errors.Is(err, cause) {
t.Fatalf("candidate decode error = %T %v, want typed wrapping error", err, err)
}
}
func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
shared := []byte(`{"items":["one"]}`)
codec := notesCodec()
@@ -275,6 +349,10 @@ func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
content[0] = '['
return codecNotes{Items: []string{"one"}}, nil
}
codec.candidateDecodeFunc = func(content []byte) (codecNotes, error) {
content[0] = '['
return codecNotes{Items: []string{"candidate"}}, nil
}
registry := NewArtifactCodecRegistry()
if err := RegisterArtifactCodec(registry, codec); err != nil {
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
@@ -295,6 +373,18 @@ func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
if !bytes.Equal(artifact.Content, before) {
t.Fatalf("serialized content changed during decode: %q", artifact.Content)
}
candidateContent := []byte(`{"items":["candidate"]}`)
candidateBefore := append([]byte(nil), candidateContent...)
entry, _, err := registry.entry("test/notes")
if err != nil {
t.Fatalf("entry() error = %v, want nil", err)
}
if _, err := entry.decodeCandidate(candidateContent); err != nil {
t.Fatalf("candidate decode error = %v, want nil", err)
}
if !bytes.Equal(candidateContent, candidateBefore) {
t.Fatalf("candidate content changed during decode: %q", candidateContent)
}
}
func notesCodec() testArtifactCodec[codecNotes] {
@@ -304,7 +394,7 @@ func notesCodec() testArtifactCodec[codecNotes] {
ID: "notes.v1",
Name: "notes",
Version: "v1",
JSONSchema: []byte(`{"additionalProperties":false,"properties":{"items":{"items":{"type":"string"},"type":"array"}},"required":["items"],"type":"object"}`),
JSONSchema: []byte(`{"additionalProperties":false,"properties":{"details":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"},"items":{"items":{"type":"string"},"type":"array"},"labels":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["items"],"type":"object"}`),
},
mediaType: "application/json",
encodeFunc: func(value codecNotes) ([]byte, error) {

View File

@@ -17,6 +17,7 @@ import (
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
@@ -34,32 +35,6 @@ func NoopDebugRecorder() DebugRecorder { return noopDebugRecorder{} }
func (noopDebugRecorder) Enabled() bool { return false }
func (noopDebugRecorder) WriteJSON(string, any) error { return nil }
func (noopDebugRecorder) WriteBytes(string, []byte) error { return nil }
func debugPathComponent(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "_"
}
var b strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-' || r == '_' || r == '.':
b.WriteRune(r)
default:
b.WriteString(fmt.Sprintf("~%x", r))
}
}
out := b.String()
if out == "." || out == ".." || strings.Contains(out, "..") {
return "_"
}
return out
}
type debugTimedEnvelope struct {
Stage string `json:"stage,omitempty"`
@@ -242,7 +217,7 @@ func (client *debugLLMClient) CompleteStructured(ctx context.Context, req contra
}
scopePrefix := cleanDebugPath(req.StageName)
if scopePrefix == "_" {
if req.StageName == "" {
scopePrefix = "llm"
}
if scope := debugLLMScopeFromContext(ctx); scope != nil {
@@ -310,7 +285,6 @@ func withDebugLLMScope(ctx context.Context, prefix string) (context.Context, *de
if ctx == nil {
ctx = context.Background()
}
prefix = cleanDebugPath(prefix)
scope := &debugLLMScope{
prefix: prefix,
parent: debugLLMScopeFromContext(ctx),
@@ -322,7 +296,6 @@ func withIsolatedDebugLLMScope(ctx context.Context, prefix string) (context.Cont
if ctx == nil {
ctx = context.Background()
}
prefix = cleanDebugPath(prefix)
scope := &debugLLMScope{prefix: prefix}
return context.WithValue(ctx, debugLLMScopeContextKey{}, scope), scope
}
@@ -362,15 +335,12 @@ func (scope *debugLLMScope) references() []debugLLMCallReference {
}
func cleanDebugPath(value string) string {
parts := strings.Split(path.Clean(strings.TrimSpace(value)), "/")
parts := strings.Split(value, "/")
out := make([]string, 0, len(parts))
for _, part := range parts {
out = append(out, debugPathComponent(part))
out = append(out, fileio.EncodePathComponent(part))
}
if len(out) == 0 {
return "_"
}
return path.Join(out...)
return strings.Join(out, "/")
}
func debugFirstNonEmptyString(values ...string) string {

View File

@@ -1,13 +1,52 @@
package pipeline
import (
"context"
"encoding/json"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
func TestCleanDebugPathPreservesRawComponents(t *testing.T) {
for _, test := range []struct {
value string
want string
}{
{value: "", want: "%"},
{value: ".", want: "%2E"},
{value: "..", want: "%2E%2E"},
{value: "a//b", want: "a/%/b"},
{value: "/a/", want: "%/a/%"},
{value: " a ", want: "%20a%20"},
} {
if got := cleanDebugPath(test.value); got != test.want {
t.Errorf("cleanDebugPath(%q) = %q, want %q", test.value, got, test.want)
}
}
}
func TestDebugLLMPathsKeepDotIdentitiesDistinct(t *testing.T) {
recorder := newCapturedDebugRecorder()
client := WithDebugLLMRecording(attemptDebugLLM{}, recorder)
for _, test := range []struct {
stageName string
path string
}{
{stageName: ".", path: "%2E/response-0001.json"},
{stageName: "..", path: "%2E%2E/response-0002.json"},
} {
if _, err := client.CompleteStructured(context.Background(), contracts.StructuredCompletionRequest{StageName: test.stageName}, nil); err != nil {
t.Fatalf("CompleteStructured(%q): %v", test.stageName, err)
}
if !recorder.has(test.path) {
t.Errorf("debug artifact %q was not written; names = %#v", test.path, recorder.names())
}
}
}
func TestDebugSourceDocumentPreservesUnitReferences(t *testing.T) {
doc := validSourceDocument()
envelope := debugSourceDocumentEnvelope(doc)

View File

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

View File

@@ -97,14 +97,11 @@ func TestRunnerBuildsEvidenceContextFromSelectedNormalizedOutputs(t *testing.T)
}
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)
}
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"])
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)
}
}
@@ -134,7 +131,7 @@ func TestRunnerEvidenceContextOmitsAbsentAndRejectedLanes(t *testing.T) {
t.Fatalf("rejections = %#v, want rejected lane unchanged", result.Rejected)
}
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)
}
}
@@ -232,7 +229,7 @@ func TestRunnerEvidenceContextRebuildsFromAcceptedCheckpoint(t *testing.T) {
t.Fatalf("Run() error = %v", err)
}
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)
}
}

View File

@@ -18,6 +18,16 @@ type referenceTargetKey struct {
Stage ModuleStage
}
type generatedOutputKey struct {
stepID string
laneID string
}
type indexedGeneratedOutput struct {
count int
output contracts.SerializedOutput
}
func keyForReferenceTarget(target ResolvedReferenceTarget) referenceTargetKey {
return referenceTargetKey{StepID: target.StepID, LaneID: target.LaneID, Stage: target.Stage}
}
@@ -40,7 +50,8 @@ func buildStepReferenceSets(input RunInput, step PreparedPipelineStep, outputs [
}
sets := make(map[referenceTargetKey]contracts.ReferenceSet)
var provenance []artifacts.ReferenceProvenance
canonical := make(map[string]contracts.ReferenceItem)
outputsByProducer := indexGeneratedOutputs(outputs)
canonical := make(map[generatedOutputKey]contracts.ReferenceItem)
for _, prepared := range step.lanes {
lane := prepared.resolved
for _, target := range []ResolvedReferenceTarget{lane.ExtractReferences, lane.MergeReferences, lane.NormalizeReferences} {
@@ -51,7 +62,7 @@ func buildStepReferenceSets(input RunInput, step PreparedPipelineStep, outputs [
continue
}
generated = true
item, err := generatedReferenceItem(input, binding, outputs, canonical)
item, err := generatedReferenceItem(input, binding, outputsByProducer, canonical)
if err != nil {
return nil, nil, contextualHandoffError(input, target, binding, err)
}
@@ -101,31 +112,43 @@ func contextualHandoffError(input RunInput, target ResolvedReferenceTarget, bind
return fmt.Errorf("pipeline %q step %q lane %q %s generated dependency %q from %q/%q: %w", input.pipeline.ID, target.StepID, target.LaneID, target.Stage, binding.SlotName, binding.Artifact.Step, binding.Artifact.Lane, err)
}
func generatedReferenceItem(input RunInput, binding ReferenceBinding, outputs []contracts.SerializedOutput, cache map[string]contracts.ReferenceItem) (contracts.ReferenceItem, error) {
func generatedOutputKeyFor(stepID, laneID string) generatedOutputKey {
return generatedOutputKey{stepID: strings.TrimSpace(stepID), laneID: strings.TrimSpace(laneID)}
}
func indexGeneratedOutputs(outputs []contracts.SerializedOutput) map[generatedOutputKey]indexedGeneratedOutput {
indexed := make(map[generatedOutputKey]indexedGeneratedOutput, len(outputs))
for _, output := range outputs {
key := generatedOutputKeyFor(output.StepID, output.LaneID)
entry := indexed[key]
entry.count++
if entry.count == 1 {
entry.output = output
}
indexed[key] = entry
}
return indexed
}
func generatedReferenceItem(input RunInput, binding ReferenceBinding, outputsByProducer map[generatedOutputKey]indexedGeneratedOutput, cache map[generatedOutputKey]contracts.ReferenceItem) (contracts.ReferenceItem, error) {
selector := binding.Artifact
if selector == nil {
return contracts.ReferenceItem{}, fmt.Errorf("generated reference selector must not be nil")
}
stepID := strings.TrimSpace(selector.Step)
laneID := strings.TrimSpace(selector.Lane)
cacheKey := stepID + "\x00" + laneID
if item, ok := cache[cacheKey]; ok {
producerKey := generatedOutputKeyFor(selector.Step, selector.Lane)
if item, ok := cache[producerKey]; ok {
item.SlotName = strings.TrimSpace(binding.SlotName)
item.BindingSource = strings.TrimSpace(binding.BindingSource)
return contracts.CloneReferenceItem(item), nil
}
matches := make([]contracts.SerializedOutput, 0, 1)
for _, output := range outputs {
if strings.TrimSpace(output.StepID) == stepID && strings.TrimSpace(output.LaneID) == laneID {
matches = append(matches, output)
}
}
if len(matches) == 0 {
matched, ok := outputsByProducer[producerKey]
if !ok {
return contracts.ReferenceItem{}, fmt.Errorf("producer has no accepted normalized output")
}
if len(matches) > 1 {
return contracts.ReferenceItem{}, fmt.Errorf("producer has %d accepted normalized outputs; exactly one is required", len(matches))
if matched.count > 1 {
return contracts.ReferenceItem{}, fmt.Errorf("producer has %d accepted normalized outputs; exactly one is required", matched.count)
}
stepID, laneID := producerKey.stepID, producerKey.laneID
producer, ok := findResolvedLane(input.pipeline, stepID, laneID)
if !ok {
return contracts.ReferenceItem{}, fmt.Errorf("producer lane is not present in the resolved pipeline")
@@ -133,7 +156,7 @@ func generatedReferenceItem(input RunInput, binding ReferenceBinding, outputs []
if input.Prepared == nil || input.Prepared.artifactCodecs == nil {
return contracts.ReferenceItem{}, fmt.Errorf("artifact codec registry is unavailable")
}
serialized := contracts.CloneSerializedArtifact(matches[0].Artifact)
serialized := contracts.CloneSerializedArtifact(matched.output.Artifact)
if serialized.Kind != producer.ArtifactKind {
return contracts.ReferenceItem{}, fmt.Errorf("producer artifact kind %q does not match resolved kind %q", serialized.Kind, producer.ArtifactKind)
}
@@ -172,7 +195,7 @@ func generatedReferenceItem(input RunInput, binding ReferenceBinding, outputs []
cacheItem := contracts.CloneReferenceItem(item)
cacheItem.SlotName = ""
cacheItem.BindingSource = ""
cache[cacheKey] = cacheItem
cache[producerKey] = cacheItem
return item, nil
}
@@ -243,6 +266,7 @@ type generatedReferenceFingerprintIdentity struct {
SchemaDigest string `json:"schema_digest"`
MediaType string `json:"media_type"`
ContentDigest string `json:"content_digest"`
SizeBytes int64 `json:"size_bytes"`
}
// generatedReferenceDependencies returns the canonical semantic dependency
@@ -271,6 +295,7 @@ func generatedReferenceDependencies(set contracts.ReferenceSet) []CheckpointFing
SchemaDigest: contracts.DigestArtifactSchema(item.ArtifactSchema),
MediaType: item.MediaType,
ContentDigest: item.Digest,
SizeBytes: item.SizeBytes,
}
encoded, err := json.Marshal(identity)
if err != nil {

View File

@@ -3,6 +3,7 @@ package pipeline
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
@@ -106,6 +107,29 @@ func TestBuildStepReferenceSetsCanonicalizesAndClonesFanout(t *testing.T) {
}
}
func TestBuildStepReferenceSetsIndexesManyOutputsForFanout(t *testing.T) {
input, step, producerOutput := handoffFixture(t, codecNotes{Items: []string{"first"}})
outputs := make([]contracts.SerializedOutput, 0, 65)
for index := range 64 {
outputs = append(outputs, contracts.SerializedOutput{StepID: fmt.Sprintf("unrelated-step-%d", index), LaneID: "unrelated-lane"})
}
outputs = append(outputs, producerOutput)
sets, provenance, err := buildStepReferenceSets(input, step, outputs)
if err != nil {
t.Fatalf("buildStepReferenceSets() error = %v", err)
}
if len(sets) != 3 || len(provenance) != 3 {
t.Fatalf("reference sets/provenance = %d/%d, want 3/3", len(sets), len(provenance))
}
for target, set := range sets {
item := set.Slots["producer-output"].Items[0]
if item.Producer.StepID != "step-1" || item.Producer.LaneID != "notes" || string(item.Content) == "" {
t.Fatalf("target %v item = %#v, want canonical producer artifact", target, item)
}
}
}
func TestGeneratedReferenceAcceptsTypedEmptyCollection(t *testing.T) {
input, step, producerOutput := handoffFixture(t, codecNotes{})
sets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{producerOutput})
@@ -128,6 +152,12 @@ func TestGeneratedReferenceRejectsInvalidProducerOutputsDeterministically(t *tes
{name: "multiple", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
return []contracts.SerializedOutput{output, output}
}, want: "exactly one is required"},
{name: "multiple after identifier normalization", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
duplicate := contracts.CloneSerializedOutput(output)
duplicate.StepID = " step-1 "
duplicate.LaneID = " notes "
return []contracts.SerializedOutput{output, duplicate}
}, want: "producer has 2 accepted normalized outputs; exactly one is required"},
{name: "kind mismatch", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
return []contracts.SerializedOutput{output}
}, mutate: func(target *ResolvedReferenceTarget, _ *contracts.SerializedOutput) {
@@ -167,6 +197,17 @@ func TestGeneratedReferenceRejectsInvalidProducerOutputsDeterministically(t *tes
}
}
func BenchmarkIndexGeneratedOutputs(b *testing.B) {
outputs := make([]contracts.SerializedOutput, 0, 1024)
for index := range 1024 {
outputs = append(outputs, contracts.SerializedOutput{StepID: fmt.Sprintf("step-%d", index), LaneID: fmt.Sprintf("lane-%d", index)})
}
b.ResetTimer()
for range b.N {
_ = indexGeneratedOutputs(outputs)
}
}
func TestGeneratedReferenceFingerprintChangesWithCanonicalContent(t *testing.T) {
input, step, first := handoffFixture(t, codecNotes{Items: []string{"first"}})
firstSets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{first})
@@ -185,6 +226,27 @@ func TestGeneratedReferenceFingerprintChangesWithCanonicalContent(t *testing.T)
}
}
func TestGeneratedReferenceFingerprintChangesWithCanonicalSize(t *testing.T) {
input, step, producerOutput := handoffFixture(t, codecNotes{Items: []string{"first"}})
sets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{producerOutput})
if err != nil {
t.Fatalf("build reference set: %v", err)
}
key := keyForReferenceTarget(step.lanes[0].resolved.ExtractReferences)
firstDeps := generatedReferenceDependencies(sets[key])
changed := CloneReferenceSet(sets[key])
item := changed.Slots["producer-output"].Items[0]
item.SizeBytes++
changed.Slots["producer-output"] = contracts.ResolvedReferenceSlot{
Slot: changed.Slots["producer-output"].Slot,
Items: []contracts.ReferenceItem{item},
}
secondDeps := generatedReferenceDependencies(changed)
if reflect.DeepEqual(firstDeps, secondDeps) {
t.Fatalf("generated dependencies = %#v, want size-sensitive fingerprint", firstDeps)
}
}
func TestRunnerHandsOffAcceptedNormalizedOutputBeforeConsumerLanes(t *testing.T) {
input, _, _ := handoffFixture(t, codecNotes{Items: []string{"first"}})
prepared := input.Prepared

View File

@@ -305,6 +305,7 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
if name != "extract" {
return
}
request.Options["nested"].(map[string]any)["value"] = "mutated by extractor builder"
slot := request.References.Slots["extract"]
slot.Items[0].Content = []byte("mutated by extractor builder")
request.References.Slots["extract"] = slot
@@ -318,6 +319,7 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet = constructionReferenceSet("extract", "extract reference")
resolved.Steps[0].ArtifactLanes[0].MergeReferences.ReferenceSet = constructionReferenceSet("merge", "merge reference")
resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.ReferenceSet = constructionReferenceSet("normalize", "normalize reference")
resolved.Steps[0].ArtifactLanes[0].Extract.Options = constructionBuildRequest().Options
prepared, err := Prepare(resolved, registries, ModuleDependencies{})
if err != nil {
@@ -339,6 +341,15 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
if got := constructionReferenceContent(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" {
t.Fatalf("resolved extract references = %q, want original content", got)
}
if got := constructionReferenceContent(prepared.resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" {
t.Fatalf("prepared extract references = %q, want original content", got)
}
if got := resolved.Steps[0].ArtifactLanes[0].Extract.Options["nested"].(map[string]any)["value"]; got != "original" {
t.Fatalf("resolved extract options = %#v, want original nested value", got)
}
if got := prepared.resolved.Steps[0].ArtifactLanes[0].Extract.Options["nested"].(map[string]any)["value"]; got != "original" {
t.Fatalf("prepared extract options = %#v, want original nested value", got)
}
_, err = prepared.Steps[0].lanes[0].typed.extract(context.Background(), prepared.Steps[0].lanes[0].typed.extractor, contracts.TypedExtractionRequest{
References: CloneReferenceSet(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet),
@@ -354,6 +365,163 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
}
}
func TestRegisteredBuildersReceiveIndependentBuildRequests(t *testing.T) {
request := constructionBuildRequest()
probe := buildRequestMutationProbe{t: t, want: cloneBuildRequest(request)}
inputs := NewInputAdapterRegistry()
if err := inputs.RegisterBuilderWithSpec(testModuleSpec("input", StageInput), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.InputAdapter, error) {
probe.observe(request)
return &constructionInput{key: "input"}, nil
}); err != nil {
t.Fatal(err)
}
chunkers := NewChunkerRegistry()
if err := chunkers.RegisterBuilderWithSpec(testModuleSpec("chunk", StageChunk), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Chunker, error) {
probe.observe(request)
return &typedTestChunker{key: "chunk"}, nil
}); err != nil {
t.Fatal(err)
}
outputs := NewOutputEncoderRegistry()
if err := outputs.RegisterBuilderWithSpec(testModuleSpec("output", StageOutput), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.OutputEncoder, error) {
probe.observe(request)
return &typedTestOutput{key: "output"}, nil
}); err != nil {
t.Fatal(err)
}
extractors := NewExtractorRegistry()
extractSpec := testModuleSpec("extract", StageExtract)
extractSpec.ArtifactKind = "test/notes"
if err := RegisterExtractorBuilder(extractors, extractSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Extractor[codecNotes], error) {
probe.observe(request)
return typedTestExtractor[codecNotes]{key: "extract"}, nil
}); err != nil {
t.Fatal(err)
}
mergers := NewMergerRegistry()
mergeSpec := testModuleSpec("merge", StageMerge)
mergeSpec.ArtifactKind = "test/notes"
if err := RegisterMergerBuilder(mergers, mergeSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Merger[codecNotes], error) {
probe.observe(request)
return typedTestMerger[codecNotes]{key: "merge"}, nil
}); err != nil {
t.Fatal(err)
}
normalizers := NewNormalizerRegistry()
normalizeSpec := testModuleSpec("normalize", StageNormalize)
normalizeSpec.ArtifactKind = "test/notes"
if err := RegisterNormalizerBuilder(normalizers, normalizeSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Normalizer[codecNotes], error) {
probe.observe(request)
return typedTestNormalizer[codecNotes]{key: "normalize"}, nil
}); err != nil {
t.Fatal(err)
}
validators := NewValidatorRegistry()
if err := RegisterChunkValidatorBuilder(validators, ValidatorSpec{Key: "chunk-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.ChunkValidator, error) {
probe.observe(request)
return typedTestChunkValidator{key: "chunk-validator"}, nil
}); err != nil {
t.Fatal(err)
}
if err := RegisterTypedValidatorBuilder(validators, "test/notes", ValidatorSpec{Key: "typed-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.TypedValidator[codecNotes], error) {
probe.observe(request)
return typedTestValidator[codecNotes]{key: "typed-validator"}, nil
}); err != nil {
t.Fatal(err)
}
if err := RegisterSerializedValidatorBuilder(validators, SerializedValidatorSpec{ValidatorSpec: ValidatorSpec{Key: "serialized-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, SupportsArtifacts: true}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.SerializedValidator, error) {
probe.observe(request)
return typedTestSerializedValidator{key: "serialized-validator"}, nil
}); err != nil {
t.Fatal(err)
}
builders := []struct {
name string
call func() error
}{
{name: "input", call: func() error { _, err := inputs.BuildWithRequest("input", request); return err }},
{name: "chunk", call: func() error { _, err := chunkers.BuildWithRequest("chunk", request); return err }},
{name: "output", call: func() error { _, err := outputs.BuildWithRequest("output", request); return err }},
{name: "extract", call: func() error {
entry, _ := extractors.typedEntry("extract")
_, err := entry.builder(request)
return err
}},
{name: "merge", call: func() error {
entry, _ := mergers.typedEntry("merge", "test/notes")
_, err := entry.builder(request)
return err
}},
{name: "normalize", call: func() error {
entry, _ := normalizers.typedEntry("normalize", "test/notes")
_, err := entry.builder(request)
return err
}},
{name: "chunk validator", call: func() error {
entry, _ := validators.chunkEntry("chunk-validator")
_, err := entry.builder(request)
return err
}},
{name: "typed validator", call: func() error {
entry, _ := validators.typedEntry("typed-validator", "test/notes")
_, err := entry.builder(request)
return err
}},
{name: "serialized validator", call: func() error {
entry, _ := validators.serializedEntry("serialized-validator")
_, err := entry.builder(request)
return err
}},
}
for _, builder := range builders {
t.Run(builder.name, func(t *testing.T) {
if err := builder.call(); err != nil {
t.Fatal(err)
}
})
}
if !reflect.DeepEqual(request, probe.want) {
t.Fatalf("build request = %#v, want unchanged %#v", request, probe.want)
}
}
type buildRequestMutationProbe struct {
t *testing.T
want BuildRequest
}
func (probe buildRequestMutationProbe) observe(request BuildRequest) {
probe.t.Helper()
if !reflect.DeepEqual(request, probe.want) {
probe.t.Fatalf("builder request = %#v, want independently owned %#v", request, probe.want)
}
options := request.Options["nested"].(map[string]any)
options["value"] = "mutated"
request.Options["items"].([]any)[0].(map[string]any)["value"] = "mutated"
request.Options["bytes"].([]byte)[0] = 'x'
slot := request.References.Slots["reference"]
slot.Items[0].Content[0] = 'x'
slot.Items = append(slot.Items, contracts.ReferenceItem{SlotName: "reference", Content: []byte("extra")})
request.References.Slots["reference"] = slot
delete(request.References.Slots, "unused")
}
func constructionBuildRequest() BuildRequest {
return BuildRequest{
Options: map[string]any{
"nested": map[string]any{"value": "original"},
"items": []any{map[string]any{"value": "original"}},
"bytes": []byte("original"),
},
References: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
"reference": {Slot: contracts.ReferenceSlot{Name: "reference"}, Items: []contracts.ReferenceItem{{SlotName: "reference", Content: []byte("original")}}},
"unused": {Slot: contracts.ReferenceSlot{Name: "unused"}},
}},
}
}
func TestPrepareFailuresOccurBeforeInputParse(t *testing.T) {
tests := []struct {
name string

View File

@@ -100,7 +100,7 @@ func Prepare(resolved ResolvedPipeline, registries Registries, deps ModuleDepend
artifactCodecs: registries.ArtifactCodecs,
}
request := func(binding ModuleBinding, references contracts.ReferenceSet) BuildRequest {
return BuildRequest{Dependencies: deps, Options: cloneOptions(binding.Options), References: references}
return BuildRequest{Dependencies: deps, Options: binding.Options, References: references}
}
input, err := registries.Inputs.BuildWithRequest(stable.Input.Module, request(stable.Input, contracts.ReferenceSet{}))
@@ -202,7 +202,7 @@ func prepareEvidencePlan(resolved ResolvedPipeline, registries Registries, outpu
func prepareLane(pipeline ResolvedPipeline, lane ResolvedArtifactLane, registries Registries, deps ModuleDependencies) (preparedLaneExecutor, error) {
executor := preparedLaneExecutor{resolved: cloneResolvedArtifactLane(lane)}
request := func(binding ModuleBinding, references contracts.ReferenceSet) BuildRequest {
return BuildRequest{Dependencies: deps, Options: cloneOptions(binding.Options), References: references}
return BuildRequest{Dependencies: deps, Options: binding.Options, References: references}
}
extractEntry, ok := registries.Extractors.typedEntry(lane.Extract.Module)
if !ok {
@@ -259,7 +259,7 @@ func prepareValidatorChain(pipeline ResolvedPipeline, registries Registries, dep
resolved := resolvedValidatorChain(stage, laneID, moduleKey, pipeline.ValidatorChains)
prepared := preparedValidatorChain{resolved: resolved}
for _, validator := range resolved.Validators {
request := BuildRequest{Dependencies: deps, Options: cloneOptions(validator.Binding.Options), References: references}
request := BuildRequest{Dependencies: deps, Options: validator.Binding.Options, References: references}
built, err := buildPreparedValidator(registries.Validators, validator, request)
if err != nil {
return preparedValidatorChain{}, constructionError(pipeline.ID, laneID, stage, moduleKey, validator.Binding.Module, err)
@@ -280,7 +280,7 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida
if !ok {
return preparedValidator{}, fmt.Errorf("typed construction entry is not registered")
}
implementation, err = entry.builder(cloneBuildRequest(request))
implementation, err = entry.builder(request)
prepared.typed = implementation
prepared.typedValidate = entry.validate
case ValidatorTargetChunk:
@@ -288,14 +288,14 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida
if !ok {
return preparedValidator{}, fmt.Errorf("chunk construction entry is not registered")
}
prepared.chunk, err = entry.builder(cloneBuildRequest(request))
prepared.chunk, err = entry.builder(request)
implementation = prepared.chunk
case ValidatorTargetSerialized:
entry, ok := registry.serializedEntry(key)
if !ok {
return preparedValidator{}, fmt.Errorf("serialized construction entry is not registered")
}
prepared.serialized, err = entry.builder(cloneBuildRequest(request))
prepared.serialized, err = entry.builder(request)
implementation = prepared.serialized
default:
return preparedValidator{}, fmt.Errorf("validator construction target %q is not supported", resolved.Target)
@@ -323,7 +323,7 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida
}
func buildErasedModule(builder func(BuildRequest) (any, error), request BuildRequest, key, kind string) (any, error) {
implementation, err := builder(cloneBuildRequest(request))
implementation, err := builder(request)
if err != nil {
return nil, err
}

View File

@@ -305,7 +305,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
return ResolvedPipeline{}, err
}
input := resolveBinding(profile.Input, "")
input, err := resolveBinding(profile.Input, "", fmt.Sprintf("pipeline %q input reference slot", pipelineID))
if err != nil {
return ResolvedPipeline{}, err
}
if input.Module == "" {
return ResolvedPipeline{}, fmt.Errorf("pipeline %q input module must not be empty", pipelineID)
}
@@ -320,7 +323,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
}
capabilities.add(inputModuleSpec.Provides...)
chunk := resolveBinding(profile.Chunk, DefaultChunkModule)
chunk, err := resolveBinding(profile.Chunk, DefaultChunkModule, fmt.Sprintf("pipeline %q chunk reference slot", pipelineID))
if err != nil {
return ResolvedPipeline{}, err
}
chunkSpec, err := chunkerSpec(catalog, chunk.Module)
if err != nil {
return ResolvedPipeline{}, moduleLookupError(pipelineID, "", StageChunk, chunk.Module, err)
@@ -363,6 +369,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
if err != nil {
return ResolvedPipeline{}, err
}
output, err := resolveBinding(profile.Output, DefaultOutputModule, fmt.Sprintf("pipeline %q output reference slot", pipelineID))
if err != nil {
return ResolvedPipeline{}, err
}
resolved := ResolvedPipeline{
ID: pipelineID,
Input: input,
@@ -370,7 +380,7 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
Chunk: chunk,
ChunkExecutionClass: chunkSpec.ExecutionClass,
ChunkReferences: referenceTarget(StageChunk, "", chunk.Module, chunkReferences),
Output: resolveBinding(profile.Output, DefaultOutputModule),
Output: output,
}
chunkValidatorChain, err := resolveValidatorChain(pipelineID, "", StageChunk, chunk.Module, chunk.Validators, "", nil, catalog)
if err != nil {
@@ -485,13 +495,29 @@ func resolveArtifactLane(
inherited capabilitySet,
catalog ModuleCatalog,
) (ResolvedArtifactLane, []ResolvedValidatorChain, capabilitySet, error) {
extract, err := resolveBinding(profile.Extract, "", fmt.Sprintf("pipeline %q step %q lane %q extract reference slot", pipelineID, stepID, laneID))
if err != nil {
return ResolvedArtifactLane{}, nil, nil, err
}
merge, err := resolveBinding(profile.Merge, DefaultMergeModule, fmt.Sprintf("pipeline %q step %q lane %q merge reference slot", pipelineID, stepID, laneID))
if err != nil {
return ResolvedArtifactLane{}, nil, nil, err
}
normalize, err := resolveBinding(profile.Normalize, DefaultNormalizeModule, fmt.Sprintf("pipeline %q step %q lane %q normalize reference slot", pipelineID, stepID, laneID))
if err != nil {
return ResolvedArtifactLane{}, nil, nil, err
}
validators, err := resolveBindings(profile.Validators, "", fmt.Sprintf("pipeline %q step %q lane %q validator reference slot", pipelineID, stepID, laneID))
if err != nil {
return ResolvedArtifactLane{}, nil, nil, err
}
lane := ResolvedArtifactLane{
StepID: strings.TrimSpace(stepID),
ID: laneID,
Extract: resolveBinding(profile.Extract, ""),
Merge: resolveBinding(profile.Merge, DefaultMergeModule),
Normalize: resolveBinding(profile.Normalize, DefaultNormalizeModule),
Validators: resolveBindings(profile.Validators, ""),
Extract: extract,
Merge: merge,
Normalize: normalize,
Validators: validators,
}
if lane.Extract.Module == "" {
return ResolvedArtifactLane{}, nil, nil, fmt.Errorf("pipeline %q lane %q extract module must not be empty", pipelineID, laneID)
@@ -516,6 +542,7 @@ func resolveArtifactLane(
extractReferences := mergeReferenceMaps(profile.References, lane.Extract.References)
references, err := resolveReferenceTargetBindings(referenceResolutionTarget{
PipelineID: pipelineID,
StepID: strings.TrimSpace(stepID),
LaneID: laneID,
Stage: StageExtract,
Module: lane.Extract.Module,
@@ -541,6 +568,7 @@ func resolveArtifactLane(
}
mergeReferences, err := resolveReferenceTargetBindings(referenceResolutionTarget{
PipelineID: pipelineID,
StepID: strings.TrimSpace(stepID),
LaneID: laneID,
Stage: StageMerge,
Module: lane.Merge.Module,
@@ -566,6 +594,7 @@ func resolveArtifactLane(
}
normalizeReferences, err := resolveReferenceTargetBindings(referenceResolutionTarget{
PipelineID: pipelineID,
StepID: strings.TrimSpace(stepID),
LaneID: laneID,
Stage: StageNormalize,
Module: lane.Normalize.Module,
@@ -925,7 +954,7 @@ func validatePipelineReferenceDefaults(
lanesByID map[string]ArtifactLaneProfile,
catalog ModuleCatalog,
) error {
normalizedPipelineReferences, err := normalizedReferenceMap(pipelineReferences, fmt.Sprintf("pipeline %q reference slot", pipelineID))
normalizedPipelineReferences, err := normalizeReferenceMap(pipelineReferences, fmt.Sprintf("pipeline %q reference slot", pipelineID))
if err != nil {
return err
}
@@ -939,38 +968,38 @@ func validatePipelineReferenceDefaults(
}
for _, laneID := range sortedArtifactLaneProfileKeys(lanesByID) {
laneProfile := lanesByID[laneID]
extract := resolveBinding(laneProfile.Extract, "")
if extract.Module == "" {
extractModule := resolveModuleKey(laneProfile.Extract.Module, "")
if extractModule == "" {
return fmt.Errorf("pipeline %q lane %q extract module must not be empty", pipelineID, laneID)
}
extractSpec, err := extractorSpec(catalog, extract.Module)
extractSpec, err := extractorSpec(catalog, extractModule)
if err != nil {
return moduleLookupError(pipelineID, laneID, StageExtract, extract.Module, err)
return moduleLookupError(pipelineID, laneID, StageExtract, extractModule, err)
}
for _, slot := range extractSpec.ReferenceSlots {
declaredByAnyTarget[slot.Name] = struct{}{}
}
merge := resolveBinding(laneProfile.Merge, DefaultMergeModule)
mergeModule := resolveModuleKey(laneProfile.Merge.Module, DefaultMergeModule)
var artifactType reflect.Type
artifactKind := extractSpec.ArtifactKind
if artifactKind != "" && catalog.Extractors != nil {
if entry, ok := catalog.Extractors.typedEntry(extract.Module); ok {
if entry, ok := catalog.Extractors.typedEntry(extractModule); ok {
artifactType = entry.valueType
}
}
mergeSpec, err := mergerSpecForArtifact(catalog, merge.Module, artifactKind, artifactType)
mergeSpec, err := mergerSpecForArtifact(catalog, mergeModule, artifactKind, artifactType)
if err != nil {
return moduleLookupError(pipelineID, laneID, StageMerge, merge.Module, err)
return moduleLookupError(pipelineID, laneID, StageMerge, mergeModule, err)
}
for _, slot := range mergeSpec.ReferenceSlots {
declaredByAnyTarget[slot.Name] = struct{}{}
}
normalize := resolveBinding(laneProfile.Normalize, DefaultNormalizeModule)
normalizeSpec, err := normalizerSpecForArtifact(catalog, normalize.Module, artifactKind, artifactType)
normalizeModule := resolveModuleKey(laneProfile.Normalize.Module, DefaultNormalizeModule)
normalizeSpec, err := normalizerSpecForArtifact(catalog, normalizeModule, artifactKind, artifactType)
if err != nil {
return moduleLookupError(pipelineID, laneID, StageNormalize, normalize.Module, err)
return moduleLookupError(pipelineID, laneID, StageNormalize, normalizeModule, err)
}
for _, slot := range normalizeSpec.ReferenceSlots {
declaredByAnyTarget[slot.Name] = struct{}{}
@@ -987,6 +1016,7 @@ func validatePipelineReferenceDefaults(
type referenceResolutionTarget struct {
PipelineID string
StepID string
LaneID string
Stage ModuleStage
Module string
@@ -1046,7 +1076,7 @@ func resolveReferenceTargetBindings(target referenceResolutionTarget) ([]Referen
return nil
}
normalizedPipelineReferences, err := normalizedReferenceMap(target.PipelineReferences, fmt.Sprintf("pipeline %q reference slot", target.PipelineID))
normalizedPipelineReferences, err := normalizeReferenceMap(target.PipelineReferences, fmt.Sprintf("pipeline %q reference slot", target.PipelineID))
if err != nil {
return nil, err
}
@@ -1059,7 +1089,7 @@ func resolveReferenceTargetBindings(target referenceResolutionTarget) ([]Referen
}
}
normalizedLocalReferences, err := normalizedReferenceMap(target.LocalReferences, referenceTargetSlotLabel(target))
normalizedLocalReferences, err := normalizeReferenceMap(target.LocalReferences, referenceTargetSlotLabel(target))
if err != nil {
return nil, err
}
@@ -1166,6 +1196,9 @@ func sortedReferenceBindings(bindings map[string]ReferenceBinding) []ReferenceBi
func referenceTargetErrorContext(target referenceResolutionTarget) string {
if target.LaneID != "" {
if target.StepID != "" {
return fmt.Sprintf("pipeline %q step %q lane %q %s module %q", target.PipelineID, target.StepID, target.LaneID, target.Stage, target.Module)
}
return fmt.Sprintf("pipeline %q lane %q %s module %q", target.PipelineID, target.LaneID, target.Stage, target.Module)
}
return fmt.Sprintf("pipeline %q %s module %q", target.PipelineID, target.Stage, target.Module)
@@ -1173,17 +1206,22 @@ func referenceTargetErrorContext(target referenceResolutionTarget) string {
func referenceTargetSlotLabel(target referenceResolutionTarget) string {
if target.LaneID != "" {
if target.StepID != "" {
return fmt.Sprintf("pipeline %q step %q lane %q %s reference slot", target.PipelineID, target.StepID, target.LaneID, target.Stage)
}
return fmt.Sprintf("pipeline %q lane %q %s reference slot", target.PipelineID, target.LaneID, target.Stage)
}
return fmt.Sprintf("pipeline %q %s reference slot", target.PipelineID, target.Stage)
}
func normalizedReferenceMap(values map[string]ReferenceSource, keyName string) (map[string]ReferenceSource, error) {
func normalizeReferenceMap(values map[string]ReferenceSource, keyName string) (map[string]ReferenceSource, error) {
if len(values) == 0 {
return nil, nil
}
out := make(map[string]ReferenceSource, len(values))
for rawSlotName, rawSource := range values {
rawSlotNames := sortedStringMapKeys(values)
for _, rawSlotName := range rawSlotNames {
rawSource := values[rawSlotName]
slotName := strings.TrimSpace(rawSlotName)
if slotName == "" {
return nil, fmt.Errorf("%s must not be empty", keyName)
@@ -1236,20 +1274,29 @@ func sortedReferenceBindingKeys(values map[string]ReferenceBinding) []string {
return keys
}
func resolveBinding(binding ModuleBinding, defaultModule string) ModuleBinding {
module := strings.TrimSpace(binding.Module)
if module == "" {
module = defaultModule
}
func resolveBinding(binding ModuleBinding, defaultModule string, referenceSlotLabel string) (ModuleBinding, error) {
module := resolveModuleKey(binding.Module, defaultModule)
llmProfile := strings.TrimSpace(binding.LLMProfile)
references, err := normalizeReferenceMap(binding.References, referenceSlotLabel)
if err != nil {
return ModuleBinding{}, err
}
return ModuleBinding{
Module: module,
LLMProfile: llmProfile,
Retries: binding.Retries,
Options: cloneOptions(binding.Options),
References: normalizeReferenceMap(binding.References),
References: references,
Validators: cloneValidatorOverride(binding.Validators),
}, nil
}
func resolveModuleKey(module string, defaultModule string) string {
module = strings.TrimSpace(module)
if module == "" {
return defaultModule
}
return module
}
func applyEffectiveLLMProfiles(resolved *ResolvedPipeline, pipelineProfile, overrideProfile string) error {
@@ -1312,17 +1359,20 @@ func applyEffectiveLLMProfiles(resolved *ResolvedPipeline, pipelineProfile, over
return nil
}
func resolveBindings(bindings []ModuleBinding, defaultModule string) []ModuleBinding {
func resolveBindings(bindings []ModuleBinding, defaultModule string, referenceSlotLabel string) ([]ModuleBinding, error) {
if len(bindings) == 0 {
return nil
return nil, nil
}
resolved := make([]ModuleBinding, 0, len(bindings))
for _, binding := range bindings {
resolvedBinding := resolveBinding(binding, defaultModule)
for index, binding := range bindings {
resolvedBinding, err := resolveBinding(binding, defaultModule, fmt.Sprintf("%s %d", referenceSlotLabel, index))
if err != nil {
return nil, err
}
resolved = append(resolved, resolvedBinding)
}
return resolved
return resolved, nil
}
func cloneOptions(options map[string]any) map[string]any {
@@ -1358,25 +1408,6 @@ func cloneOptionValue(value any) any {
}
}
func normalizeReferenceMap(values map[string]ReferenceSource) map[string]ReferenceSource {
if len(values) == 0 {
return nil
}
out := make(map[string]ReferenceSource, len(values))
keys := make([]string, 0, len(values))
rawByNormalized := make(map[string]string, len(values))
for rawKey := range values {
key := strings.TrimSpace(rawKey)
rawByNormalized[key] = rawKey
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
out[key] = cloneReferenceSource(values[rawByNormalized[key]])
}
return out
}
func selectedArtifactLanes(pipelineID string, artifacts map[string]ArtifactLaneProfile, options ResolveOptions) (map[string]ArtifactLaneProfile, []string, error) {
lanesByID := make(map[string]ArtifactLaneProfile, len(artifacts))
for rawLaneID, lane := range artifacts {

View File

@@ -159,6 +159,85 @@ func TestResolvePipelineAppliesDefaults(t *testing.T) {
}
}
func TestResolvePipelineRejectsReferenceSlotCollisionsAfterTrimming(t *testing.T) {
for _, tt := range []struct {
name string
configure func(*PipelineProfile)
want []string
}{
{
name: "chunk",
configure: func(profile *PipelineProfile) {
profile.Chunk = ModuleBinding{Module: DefaultChunkModule, References: map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}}
},
want: []string{`pipeline "references"`, "chunk", `reference slot "rules" is duplicated after trimming`},
},
{
name: "extract",
configure: func(profile *PipelineProfile) {
lane := profile.Artifacts["lane"]
lane.Extract.References = map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}
profile.Artifacts["lane"] = lane
},
want: []string{`pipeline "references"`, `step "default"`, `lane "lane"`, "extract", `reference slot "rules" is duplicated after trimming`},
},
{
name: "merge",
configure: func(profile *PipelineProfile) {
lane := profile.Artifacts["lane"]
lane.Merge = ModuleBinding{Module: DefaultMergeModule, References: map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}}
profile.Artifacts["lane"] = lane
},
want: []string{`pipeline "references"`, `step "default"`, `lane "lane"`, "merge", `reference slot "rules" is duplicated after trimming`},
},
{
name: "normalize",
configure: func(profile *PipelineProfile) {
lane := profile.Artifacts["lane"]
lane.Normalize = ModuleBinding{Module: DefaultNormalizeModule, References: map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}}
profile.Artifacts["lane"] = lane
},
want: []string{`pipeline "references"`, `step "default"`, `lane "lane"`, "normalize", `reference slot "rules" is duplicated after trimming`},
},
} {
t.Run(tt.name, func(t *testing.T) {
profile := PipelineProfile{
ID: "references",
Input: Binding("text"),
Artifacts: map[string]ArtifactLaneProfile{
"lane": {Extract: Binding("event-extractor")},
},
}
tt.configure(&profile)
_, err := ResolvePipeline(profile, ResolveOptions{}, newProfileCatalog(t))
if err == nil {
t.Fatal("ResolvePipeline() error = nil, want normalized reference collision")
}
for _, fragment := range tt.want {
if !strings.Contains(err.Error(), fragment) {
t.Fatalf("ResolvePipeline() error = %q, want context %q", err, fragment)
}
}
})
}
}
func TestResolvePipelineRejectsEmptyReferenceSlotAfterTrimming(t *testing.T) {
_, err := ResolvePipeline(PipelineProfile{
ID: "references",
Input: Binding("text"),
Chunk: ModuleBinding{Module: DefaultChunkModule, References: map[string]ReferenceSource{
" \t ": ExternalReference("rules.md"),
}},
Artifacts: map[string]ArtifactLaneProfile{
"lane": {Extract: Binding("event-extractor")},
},
}, ResolveOptions{}, newProfileCatalog(t))
if err == nil || !strings.Contains(err.Error(), `pipeline "references" chunk reference slot must not be empty`) {
t.Fatalf("ResolvePipeline() error = %v, want contextual empty reference-slot rejection", err)
}
}
func TestResolvePipelineAppliesEffectiveLLMProfiles(t *testing.T) {
for _, test := range []struct {
name string

View File

@@ -3,7 +3,10 @@ package pipeline
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"math"
"mime"
"net/url"
"os"
@@ -124,10 +127,30 @@ func materializeReferenceTarget(
if err != nil {
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q: %w", referenceTargetContext(pipelineID, target), slotName, binding.Source, err)
}
content, err := os.ReadFile(path)
file, err := os.Open(path)
if err != nil {
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q read %q: %w", referenceTargetContext(pipelineID, target), slotName, path, err)
}
regularFileSize := int64(0)
if info, statErr := file.Stat(); statErr == nil && info.Mode().IsRegular() {
regularFileSize = info.Size()
}
content, err := readReferenceContent(file, slot.MaxBytes)
closeErr := file.Close()
if err == nil && closeErr != nil {
err = closeErr
}
if err != nil {
var sizeErr *referenceSizeLimitError
if errors.As(err, &sizeErr) {
sizeBytes := sizeErr.SizeBytes
if regularFileSize > sizeBytes {
sizeBytes = regularFileSize
}
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q is %d bytes, limit %d", referenceTargetContext(pipelineID, target), slotName, path, sizeBytes, slot.MaxBytes)
}
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q read %q: %w", referenceTargetContext(pipelineID, target), slotName, path, err)
}
if !utf8.Valid(content) {
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q must be UTF-8 text", referenceTargetContext(pipelineID, target), slotName, path)
}
@@ -135,9 +158,6 @@ func materializeReferenceTarget(
if !referenceMediaTypeAccepted(mediaType, slot.AcceptedMediaTypes) {
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q media type %q is not accepted", referenceTargetContext(pipelineID, target), slotName, path, mediaType)
}
if slot.MaxBytes > 0 && int64(len(content)) > slot.MaxBytes {
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q is %d bytes, limit %d", referenceTargetContext(pipelineID, target), slotName, path, len(content), slot.MaxBytes)
}
if len(content) == 0 {
warnings = append(warnings, contracts.Warning{
Scope: referenceWarningScope(pipelineID, target, slotName),
@@ -163,6 +183,32 @@ func materializeReferenceTarget(
return set, warnings, nil
}
type referenceSizeLimitError struct {
SizeBytes int64
MaxBytes int64
}
func (err *referenceSizeLimitError) Error() string {
return fmt.Sprintf("reference is %d bytes, limit %d", err.SizeBytes, err.MaxBytes)
}
func readReferenceContent(reader io.Reader, maxBytes int64) ([]byte, error) {
if maxBytes <= 0 {
return io.ReadAll(reader)
}
if maxBytes == math.MaxInt64 {
return nil, fmt.Errorf("maximum reference size %d cannot be safely bounded", maxBytes)
}
content, err := io.ReadAll(io.LimitReader(reader, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(content)) > maxBytes {
return nil, &referenceSizeLimitError{SizeBytes: int64(len(content)), MaxBytes: maxBytes}
}
return content, nil
}
func referenceTargetSpec(target ResolvedReferenceTarget, artifactKind contracts.ArtifactKind, catalog ModuleCatalog) (ModuleSpec, error) {
switch target.Stage {
case StageChunk:

View File

@@ -2,6 +2,9 @@ package pipeline
import (
"encoding/json"
"errors"
"io"
"math"
"os"
"path/filepath"
"reflect"
@@ -12,6 +15,61 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
type countingReader struct {
content []byte
consumed int
}
func (reader *countingReader) Read(buffer []byte) (int, error) {
if len(reader.content) == 0 {
return 0, io.EOF
}
read := copy(buffer, reader.content)
reader.content = reader.content[read:]
reader.consumed += read
return read, nil
}
func TestReadReferenceContentBoundsPositiveMaximum(t *testing.T) {
tests := []struct {
name string
content string
maxBytes int64
wantContent string
wantConsumed int
wantLimitErr bool
wantReadError bool
}{
{name: "exact maximum", content: "abc", maxBytes: 3, wantContent: "abc", wantConsumed: 3},
{name: "oversized", content: "abcdef", maxBytes: 3, wantConsumed: 4, wantLimitErr: true},
{name: "unbounded", content: "abcdef", maxBytes: 0, wantContent: "abcdef", wantConsumed: 6},
{name: "negative maximum remains unbounded", content: "abcdef", maxBytes: -1, wantContent: "abcdef", wantConsumed: 6},
{name: "overflowing maximum", content: "abcdef", maxBytes: math.MaxInt64, wantConsumed: 0, wantReadError: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := &countingReader{content: []byte(tt.content)}
content, err := readReferenceContent(reader, tt.maxBytes)
if reader.consumed != tt.wantConsumed {
t.Fatalf("reader consumed %d bytes, want %d", reader.consumed, tt.wantConsumed)
}
if got := string(content); got != tt.wantContent {
t.Fatalf("content = %q, want %q", got, tt.wantContent)
}
var sizeErr *referenceSizeLimitError
if errors.As(err, &sizeErr) != tt.wantLimitErr {
t.Fatalf("error = %v, want limit error=%t", err, tt.wantLimitErr)
}
if (err != nil) != (tt.wantLimitErr || tt.wantReadError) {
t.Fatalf("error = %v, want error=%t", err, tt.wantLimitErr || tt.wantReadError)
}
if sizeErr != nil && (sizeErr.SizeBytes != 4 || sizeErr.MaxBytes != 3) {
t.Fatalf("size error = %#v, want observed size 4 and limit 3", sizeErr)
}
})
}
}
func TestMaterializeReferencesResolvesPathsAndDigestsContent(t *testing.T) {
configDir := t.TempDir()
workingDir := t.TempDir()

View File

@@ -15,6 +15,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
@@ -80,6 +81,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
if r == nil {
return output, fmt.Errorf("runner must not be nil")
}
if err := ctx.Err(); err != nil {
return output, err
}
if err := validateRunInput(input); err != nil {
return output, err
}
@@ -164,6 +168,10 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
if metadataErr != nil {
return failOutput(output), fmt.Errorf("clone input adapter metadata: %w", metadataErr)
}
if err := ctx.Err(); err != nil {
_ = checkpoints.SourceFailed(adapter.Key(), err)
return failOutput(output), err
}
doc, err = adapter.Parse(ctx, contracts.ParseRequest{
SourceID: input.SourceID,
Path: input.Path,
@@ -171,6 +179,10 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
LLMProfile: input.pipeline.Input.LLMProfile,
Metadata: requestMetadata,
})
if ctxErr := ctx.Err(); ctxErr != nil {
_ = checkpoints.SourceFailed(adapter.Key(), ctxErr)
return failOutput(output), ctxErr
}
if err != nil {
_ = checkpoints.SourceFailed(adapter.Key(), err)
return failOutput(output), fmt.Errorf("parse input with adapter %q: %w", adapter.Key(), err)
@@ -228,9 +240,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
if chunkResult.rejection != nil {
output.Rejected = append(output.Rejected, *chunkResult.rejection)
}
if chunkResult.accepted || chunkResult.lookup.Status == ChunkPlanHit {
output.Warnings = append(output.Warnings, chunkResult.warnings...)
}
output.Warnings = append(output.Warnings, chunkResult.warnings...)
chunkDebugPayload := map[string]any{
"cache_mode": chunkMode,
"lookup": chunkResult.lookup,
@@ -280,6 +290,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
return failOutput(output), err
}
}
if err := ctx.Err(); err != nil {
return failOutput(output), err
}
if len(output.Rejected) > 0 {
output.Manifest.ValidationStatus = "rejected"
@@ -293,11 +306,17 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
if err := attachModuleManifestMetadata(&output, "output", encoder); err != nil {
return failOutput(output), err
}
if err := ctx.Err(); err != nil {
return failOutput(output), err
}
outputStarted := time.Now().UTC()
evidenceArtifact, evidenceSummary, err := buildOutputEvidenceContext(input.Prepared, doc, output.NormalizeOutputs)
if err != nil {
return failOutput(output), err
}
if err := ctx.Err(); err != nil {
return failOutput(output), err
}
if evidenceSummary != nil {
if err := writeDebugTimed(debugRecorder, "output/evidence-context.json", debugTimedEnvelope{
Stage: string(StageOutput),
@@ -307,6 +326,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
}); err != nil {
return failOutput(output), fmt.Errorf("write evidence context debug artifact: %w", err)
}
if err := ctx.Err(); err != nil {
return failOutput(output), err
}
}
outputDebugPayload := map[string]any{
"manifest": output.Manifest,
@@ -327,10 +349,16 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
}); err != nil {
return failOutput(output), fmt.Errorf("write output debug artifact: %w", err)
}
if err := ctx.Err(); err != nil {
return failOutput(output), err
}
outputMetadata, err := cloneMetadata(input.Metadata)
if err != nil {
return failOutput(output), fmt.Errorf("clone output encoder metadata: %w", err)
}
if err := ctx.Err(); err != nil {
return failOutput(output), err
}
encoded, err := encoder.Encode(ctx, contracts.OutputRequest{
Manifest: output.Manifest,
NormalizeOutputs: cloneSerializedOutputs(output.NormalizeOutputs),
@@ -341,7 +369,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
ChunkMap: contracts.CloneSerializedArtifactPointer(acceptedChunkMap),
EvidenceContext: contracts.CloneSerializedArtifactPointer(evidenceArtifact),
})
output.Warnings = append(output.Warnings, encoded.Warnings...)
if ctxErr := ctx.Err(); ctxErr != nil {
return failOutput(output), ctxErr
}
if err != nil {
return failOutput(output), fmt.Errorf("encode output with encoder %q: %w", encoder.Key(), err)
}
@@ -349,7 +379,6 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
if err != nil {
return failOutput(output), fmt.Errorf("validate output files from encoder %q: %w", encoder.Key(), err)
}
output.OutputFiles = files
if err := writeDebugTimed(debugRecorder, "output/output.json", debugTimedEnvelope{
Stage: string(StageOutput),
ModuleKey: encoder.Key(),
@@ -361,6 +390,11 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
}); err != nil {
return failOutput(output), fmt.Errorf("write output debug artifact: %w", err)
}
if err := ctx.Err(); err != nil {
return failOutput(output), err
}
output.Warnings = append(output.Warnings, encoded.Warnings...)
output.OutputFiles = files
return output, nil
}
@@ -386,45 +420,52 @@ func (r *Runner) runPreparedSteps(ctx context.Context, input RunInput, checkpoin
return nil
}
func runWithRetry(ctx context.Context, retries int, run func(attempt int) (bool, *contracts.RejectedOutput, error)) (bool, *contracts.RejectedOutput, error) {
type retryAttemptResult struct {
accepted bool
rejection *contracts.RejectedOutput
warnings []contracts.Warning
}
func runWithRetry(ctx context.Context, retries int, run func(attempt int) (retryAttemptResult, error)) (retryAttemptResult, error) {
attempts := retries + 1
var last *contracts.RejectedOutput
var last retryAttemptResult
for attempt := 1; attempt <= attempts; attempt++ {
if err := ctx.Err(); err != nil {
return false, nil, err
return retryAttemptResult{}, err
}
accepted, rejection, err := run(attempt)
result, err := run(attempt)
if err != nil {
var debugErr *attemptDebugPersistenceError
if errors.As(err, &debugErr) {
return false, nil, fmt.Errorf("failed after %d attempt(s): %w", attempt, err)
return retryAttemptResult{}, fmt.Errorf("failed after %d attempt(s): %w", attempt, err)
}
if attempt == attempts {
return false, nil, fmt.Errorf("failed after %d attempt(s): %w", attempt, err)
return retryAttemptResult{}, fmt.Errorf("failed after %d attempt(s): %w", attempt, err)
}
if ctxErr := ctx.Err(); ctxErr != nil {
return false, nil, ctxErr
return retryAttemptResult{}, ctxErr
}
continue
}
if accepted {
return true, nil, nil
if result.accepted {
return result, nil
}
if rejection != nil {
if result.rejection != nil {
rejection := *result.rejection
rejection.AttemptCount = attempt
last = rejection
last = retryAttemptResult{rejection: &rejection, warnings: cloneWarnings(result.warnings)}
}
if ctxErr := ctx.Err(); ctxErr != nil {
return false, nil, ctxErr
return retryAttemptResult{}, ctxErr
}
if attempt == attempts {
if last == nil {
last = &contracts.RejectedOutput{ReasonCode: "output_rejected", Message: "output rejected", AttemptCount: attempt}
if last.rejection == nil {
last.rejection = &contracts.RejectedOutput{ReasonCode: "output_rejected", Message: "output rejected", AttemptCount: attempt}
}
return false, last, nil
return last, nil
}
}
return false, last, nil
return last, nil
}
func (r *Runner) validateChunks(ctx context.Context, doc *source.SourceDocument, moduleKey string, chunks []source.Chunk, sourceInput contracts.LLMInputMaterial, sessionID string, references contracts.ReferenceSet, metadata map[string]any, prepared preparedValidatorChain, attempt int, debug DebugRecorder) ([]contracts.Warning, *contracts.RejectedOutput, error) {
@@ -437,7 +478,7 @@ func (r *Runner) validateChunks(ctx context.Context, doc *source.SourceDocument,
for index, item := range prepared.validators {
binding := item.resolved.Binding
started := time.Now().UTC()
attemptPath := path.Join("validate", debugPathComponent(string(StageChunk)), "", debugPathComponent(moduleKey), fmt.Sprintf("%02d-%s-attempt-%02d", index+1, debugPathComponent(binding.Module), attempt))
attemptPath := path.Join("validate", fileio.EncodePathComponent(string(StageChunk)), "", fileio.EncodePathComponent(moduleKey), fmt.Sprintf("%02d-%s-attempt-%02d", index+1, fileio.EncodePathComponent(binding.Module), attempt))
validatorCtx, llmScope := withIsolatedDebugLLMScope(ctx, attemptPath)
var result contracts.ValidationResult
requestMetadata, cloneErr := cloneMetadata(metadata)

View File

@@ -351,7 +351,7 @@ func TestRunnerKeepsValidatorLLMCallsOutOfModuleAttempt(t *testing.T) {
t.Fatalf("Run() error = %v, want nil", err)
}
moduleEnvelope := debug.envelope(t, "merge/notes/attempt-01.json")
validatorPath := "validate/merge/notes/typed~2fmerge/01-llm-check-attempt-01.json"
validatorPath := "validate/merge/notes/typed%2Fmerge/01-llm-check-attempt-01.json"
validatorEnvelope := debug.envelope(t, validatorPath)
if len(moduleEnvelope.LLMCalls) != 1 || !strings.Contains(moduleEnvelope.LLMCalls[0].ResponsePath, "merge/notes/attempt-01/") {
t.Fatalf("module LLM calls = %#v, want module call only", moduleEnvelope.LLMCalls)

View File

@@ -2,6 +2,7 @@ package pipeline
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -13,10 +14,12 @@ import (
)
type observedNotesCodec struct {
candidateValues []codecNotes
finalValues []codecNotes
candidateError string
finalError string
candidateValues []codecNotes
candidateDecodedValues []codecNotes
finalValues []codecNotes
candidateError string
candidateDecodeError string
finalError string
}
func (*observedNotesCodec) Kind() contracts.ArtifactKind { return "test/notes" }
@@ -49,6 +52,18 @@ func (*observedNotesCodec) Decode(content []byte) (codecNotes, error) {
return value, json.Unmarshal(content, &value)
}
func (c *observedNotesCodec) DecodeCandidate(content []byte) (codecNotes, error) {
value, err := c.Decode(content)
if err != nil {
return codecNotes{}, err
}
if c.candidateDecodeError != "" && firstNote(value) == c.candidateDecodeError {
return codecNotes{}, errors.New("candidate decoding failed")
}
c.candidateDecodedValues = append(c.candidateDecodedValues, value)
return value, nil
}
func firstNote(value codecNotes) string {
if len(value.Items) == 0 {
return ""
@@ -64,6 +79,10 @@ func (c *observedNotesCodec) finalCalls(value string) int {
return matchingNotes(c.finalValues, value)
}
func (c *observedNotesCodec) candidateDecodeCount() int {
return len(c.candidateDecodedValues)
}
func matchingNotes(values []codecNotes, value string) int {
count := 0
for _, candidate := range values {
@@ -110,6 +129,16 @@ func installObservedNotesCodec(t *testing.T, prepared *PreparedPipeline, codec *
func configureCandidateOperation(prepared *PreparedPipeline, target ModuleStage, value codecNotes) {
lane := &prepared.Steps[0].lanes[0]
switch target {
case StageExtract:
lane.typed.extract = func(context.Context, any, contracts.TypedExtractionRequest) (erasedTypedResult, error) {
return erasedTypedResult{Value: value}, nil
}
lane.typed.merge = func(context.Context, any, contracts.TypedMergeRequest[any]) (erasedTypedResult, error) {
return erasedTypedResult{Value: codecNotes{Items: []string{"merged-other"}}}, nil
}
lane.typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
return erasedTypedResult{Value: codecNotes{Items: []string{"normalized-other"}}}, nil
}
case StageMerge:
lane.typed.merge = func(context.Context, any, contracts.TypedMergeRequest[any]) (erasedTypedResult, error) {
return erasedTypedResult{Value: value}, nil
@@ -135,6 +164,8 @@ func setCandidateValidator(prepared *PreparedPipeline, target ModuleStage, appro
},
}
switch target {
case StageExtract:
prepared.Steps[0].lanes[0].extractValidators.validators = []preparedValidator{validator}
case StageMerge:
prepared.Steps[0].lanes[0].mergeValidators.validators = []preparedValidator{validator}
case StageNormalize:
@@ -142,6 +173,135 @@ func setCandidateValidator(prepared *PreparedPipeline, target ModuleStage, appro
}
}
type serializedValidationFunc func(context.Context, contracts.SerializedValidationRequest) (contracts.ValidationResult, error)
func (serializedValidationFunc) Name() string { return "candidate-serialized" }
func (serializedValidationFunc) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (validate serializedValidationFunc) Validate(ctx context.Context, request contracts.SerializedValidationRequest) (contracts.ValidationResult, error) {
return validate(ctx, request)
}
func TestRunnerIsolatesTypedValidatorCandidates(t *testing.T) {
for _, target := range []ModuleStage{StageExtract, StageMerge, StageNormalize} {
t.Run(string(target), func(t *testing.T) {
prepared := preparedAttemptDebugPipeline(t)
prepared.Steps[0].lanes[0].extractValidators = preparedValidatorChain{}
prepared.Steps[0].lanes[0].mergeValidators = preparedValidatorChain{}
prepared.Steps[0].lanes[0].normalizeValidators = preparedValidatorChain{}
codec := &observedNotesCodec{}
installObservedNotesCodec(t, prepared, codec)
candidate := codecNotes{Items: []string{"candidate-" + string(target)}, Labels: map[string]string{"label": "original"}, Details: &codecNoteDetails{Name: "original"}}
configureCandidateOperation(prepared, target, candidate)
var laterValue, serializedValue, downstreamValue codecNotes
firstValidator := preparedValidator{
resolved: ResolvedValidator{Binding: Binding("candidate-mutator"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"},
typedValidate: func(_ context.Context, _ any, request typedValidationTarget) (contracts.ValidationResult, error) {
value := request.value.(codecNotes)
value.Items[0] = "mutated"
value.Labels["label"] = "mutated"
value.Details.Name = "mutated"
return contracts.ValidationResult{Approved: true}, nil
},
}
secondValidator := preparedValidator{
resolved: ResolvedValidator{Binding: Binding("candidate-observer"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"},
typedValidate: func(_ context.Context, _ any, request typedValidationTarget) (contracts.ValidationResult, error) {
laterValue = request.value.(codecNotes)
return contracts.ValidationResult{Approved: true}, nil
},
}
serializedValidator := preparedValidator{
resolved: ResolvedValidator{Binding: Binding("candidate-serialized"), Target: ValidatorTargetSerialized, ArtifactKind: "test/notes"},
serialized: serializedValidationFunc(func(_ context.Context, request contracts.SerializedValidationRequest) (contracts.ValidationResult, error) {
if err := json.Unmarshal(request.Content, &serializedValue); err != nil {
return contracts.ValidationResult{}, err
}
return contracts.ValidationResult{Approved: true}, nil
}),
}
setCandidateValidators(prepared, target, []preparedValidator{firstValidator, secondValidator, serializedValidator})
lane := &prepared.Steps[0].lanes[0]
switch target {
case StageExtract:
lane.typed.merge = func(_ context.Context, _ any, request contracts.TypedMergeRequest[any]) (erasedTypedResult, error) {
downstreamValue = request.ExtractOutputs[0].Value.(codecNotes)
return erasedTypedResult{Value: codecNotes{Items: []string{"merged-other"}}}, nil
}
case StageMerge:
lane.typed.normalize = func(_ context.Context, _ any, request contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
downstreamValue = request.MergeOutput.Value.(codecNotes)
return erasedTypedResult{Value: codecNotes{Items: []string{"normalized-other"}}}, nil
}
}
debug := newCapturedDebugRecorder()
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Debug: debug})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
if !reflect.DeepEqual(laterValue, candidate) || !reflect.DeepEqual(serializedValue, candidate) {
t.Fatalf("validator values = typed %#v serialized %#v, want %#v", laterValue, serializedValue, candidate)
}
if codec.candidateCalls(candidate.Items[0]) != 1 || codec.candidateDecodeCount() != 2 || codec.finalCalls(candidate.Items[0]) != 1 {
t.Fatalf("candidate calls = encode %d decode %d final %d, want 1, 2, 1", codec.candidateCalls(candidate.Items[0]), codec.candidateDecodeCount(), codec.finalCalls(candidate.Items[0]))
}
switch target {
case StageExtract, StageMerge:
if !reflect.DeepEqual(downstreamValue, candidate) {
t.Fatalf("downstream value = %#v, want %#v", downstreamValue, candidate)
}
case StageNormalize:
var normalized codecNotes
if err := json.Unmarshal(output.NormalizeOutputs[0].Artifact.Content, &normalized); err != nil || !reflect.DeepEqual(normalized, candidate) {
t.Fatalf("normalized output = %#v, %v; want %#v", normalized, err, candidate)
}
}
attemptPath := fmt.Sprintf("%s/notes/attempt-01.json", target)
if target == StageExtract {
attemptPath = "extract/notes/chunk-000001/attempt-01.json"
}
payload := debug.envelope(t, attemptPath).Payload.(map[string]any)
content, err := base64.StdEncoding.DecodeString(payload["output"].(map[string]any)["content"].(map[string]any)["content_base64"].(string))
var debugValue codecNotes
if err == nil {
err = json.Unmarshal(content, &debugValue)
}
if err != nil || !reflect.DeepEqual(debugValue, candidate) {
t.Fatalf("debug candidate = %#v, %v; want %#v", debugValue, err, candidate)
}
})
}
}
func setCandidateValidators(prepared *PreparedPipeline, target ModuleStage, validators []preparedValidator) {
switch target {
case StageExtract:
prepared.Steps[0].lanes[0].extractValidators.validators = validators
case StageMerge:
prepared.Steps[0].lanes[0].mergeValidators.validators = validators
case StageNormalize:
prepared.Steps[0].lanes[0].normalizeValidators.validators = validators
}
}
func TestRunnerReportsCandidateDecodeFailure(t *testing.T) {
prepared := preparedAttemptDebugPipeline(t)
codec := &observedNotesCodec{candidateDecodeError: "decode-failure"}
installObservedNotesCodec(t, prepared, codec)
configureCandidateOperation(prepared, StageMerge, codecNotes{Items: []string{"decode-failure"}})
setCandidateValidator(prepared, StageMerge, true)
_, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input")})
var codecErr *ArtifactCodecOperationError
if err == nil || !errors.As(err, &codecErr) || codecErr.Operation != "decode candidate" || codec.finalCalls("decode-failure") != 0 {
t.Fatalf("Run() error = %v, codec error = %#v, final calls = %d; want contextual candidate decode failure", err, codecErr, codec.finalCalls("decode-failure"))
}
}
func TestRunnerRejectsCandidatesBeforeFinalEncoding(t *testing.T) {
for _, target := range []ModuleStage{StageMerge, StageNormalize} {
t.Run(string(target), func(t *testing.T) {

View File

@@ -84,14 +84,14 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
}
var producerWarnings []contracts.Warning
accepted, rejection, err := runWithRetry(ctx, input.pipeline.Chunk.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) {
retryResult, err := runWithRetry(ctx, input.pipeline.Chunk.Retries, func(attempt int) (retryAttemptResult, error) {
attemptStarted := time.Now().UTC()
attemptPath := path.Join("chunk", fmt.Sprintf("attempt-%02d", attempt))
attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath)
terminal := newAttemptTerminalRecorder(input.Debug, attemptPath, "chunk", llmScope, debugTimedEnvelope{Stage: string(StageChunk), ModuleKey: chunker.Key(), Attempt: attempt, StartedAt: attemptStarted})
requestMetadata, metadataErr := cloneMetadata(input.Metadata)
if metadataErr != nil {
return false, nil, terminal.record(nil, fmt.Errorf("clone chunk request metadata: %w", metadataErr))
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone chunk request metadata: %w", metadataErr))
}
chunkResult, callErr := chunker.Plan(attemptCtx, contracts.ChunkRequest{
Source: doc, SourceInput: sourceInput.Clone(), SessionID: sessionID,
@@ -99,21 +99,21 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
LLMProfile: input.pipeline.Chunk.LLMProfile, Metadata: requestMetadata,
})
if callErr != nil {
return false, nil, terminal.record(nil, fmt.Errorf("chunk source with chunker %q: %w", chunker.Key(), callErr))
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("chunk source with chunker %q: %w", chunker.Key(), callErr))
}
plan, chunks, validationErr := validateAndMaterializeChunkPlan(doc, chunkResult.Plan)
if validationErr != nil {
attemptErr := fmt.Errorf("validate chunk plan from chunker %q: %w", chunker.Key(), validationErr)
payload := map[string]any{"plan": debugChunkPlanEnvelope(chunkResult.Plan), "warnings": debugWarningEnvelopes(chunkResult.Warnings)}
return false, nil, terminal.record(payload, attemptErr)
return retryAttemptResult{}, terminal.record(payload, attemptErr)
}
planDigest, digestErr := source.DigestChunkPlan(plan)
if digestErr != nil {
return false, nil, terminal.record(nil, fmt.Errorf("digest generated chunk plan: %w", digestErr))
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("digest generated chunk plan: %w", digestErr))
}
producerMetadata, _, metadataErr := moduleManifestMetadata(chunker)
if metadataErr != nil {
return false, nil, terminal.record(nil, fmt.Errorf("clone chunker manifest metadata: %w", metadataErr))
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone chunker manifest metadata: %w", metadataErr))
}
profile := ""
if input.pipeline.ChunkExecutionClass == contracts.ExecutionClassLLMBacked {
@@ -137,7 +137,7 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
action = "bypassed"
}
if candidateErr := result.setCandidate(candidate, action); candidateErr != nil {
return false, nil, terminal.record(nil, fmt.Errorf("clone generated chunk plan record: %w", candidateErr))
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone generated chunk plan record: %w", candidateErr))
}
validationWarnings, rejected, validationErr := r.validateChunks(attemptCtx, doc, chunker.Key(), chunks, sourceInput, sessionID, input.pipeline.ChunkReferences.ReferenceSet, input.Metadata, input.Prepared.chunkValidators, attempt, input.Debug)
attemptWarnings := append(cloneWarnings(chunkResult.Warnings), validationWarnings...)
@@ -145,9 +145,16 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
"plan": debugChunkPlanEnvelope(plan), "materialized_chunks": debugSourceChunkEnvelopes(chunks),
"warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected),
}
if validationErr != nil || rejected != nil {
if validationErr != nil {
result.setValidation(validationWarnings, rejected, validationErr)
return false, rejected, terminal.record(payload, validationErr)
return retryAttemptResult{}, terminal.record(payload, validationErr)
}
if rejected != nil {
result.setValidation(validationWarnings, rejected, nil)
if debugErr := terminal.record(payload, nil); debugErr != nil {
return retryAttemptResult{}, debugErr
}
return retryAttemptResult{rejection: rejected, warnings: attemptWarnings}, nil
}
result.chunks = chunks
result.plan = &plan
@@ -155,9 +162,9 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
producerWarnings = cloneWarnings(chunkResult.Warnings)
result.setValidation(validationWarnings, nil, nil)
if debugErr := terminal.record(payload, nil); debugErr != nil {
return false, nil, debugErr
return retryAttemptResult{}, debugErr
}
return true, nil, nil
return retryAttemptResult{accepted: true}, nil
})
if err != nil {
if result.summary.ValidationStatus == "not_run" {
@@ -165,9 +172,10 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
}
return result, err
}
result.accepted = accepted
result.rejection = rejection
if !accepted {
result.accepted = retryResult.accepted
result.rejection = retryResult.rejection
if !retryResult.accepted {
result.warnings = cloneWarnings(retryResult.warnings)
return result, nil
}

View File

@@ -119,6 +119,96 @@ func (a *countingInputAdapter) Parse(ctx context.Context, request contracts.Pars
return a.InputAdapter.Parse(ctx, request)
}
type cancelingDebugRecorder struct {
cancel context.CancelFunc
onName string
once sync.Once
}
func (r *cancelingDebugRecorder) Enabled() bool { return true }
func (r *cancelingDebugRecorder) WriteJSON(name string, _ any) error {
if name == r.onName {
r.once.Do(r.cancel)
}
return nil
}
func (*cancelingDebugRecorder) WriteBytes(string, []byte) error { return nil }
type cancelingOutputEncoder struct {
contracts.OutputEncoder
cancel context.CancelFunc
calls atomic.Int32
}
func (e *cancelingOutputEncoder) Encode(context.Context, contracts.OutputRequest) (contracts.OutputResult, error) {
e.calls.Add(1)
e.cancel()
return contracts.OutputResult{
Files: []contracts.OutputFile{{Name: "result.txt", ContentType: "text/plain", Bytes: []byte("result")}},
Warnings: []contracts.Warning{{ReasonCode: "returned-after-cancel"}},
}, nil
}
func TestRunnerSkipsInputAdapterWhenContextIsAlreadyCanceled(t *testing.T) {
prepared := preparedConcurrentPipeline(t, 1)
adapter := &countingInputAdapter{InputAdapter: prepared.input}
prepared.input = adapter
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := New().Run(ctx, RunInput{Prepared: prepared, RawInput: []byte("input")})
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context.Canceled", err)
}
if got := adapter.calls.Load(); got != 0 {
t.Fatalf("input Parse calls = %d, want none", got)
}
}
func TestRunnerSkipsOutputEncodingAfterDebugCancellation(t *testing.T) {
prepared := preparedConcurrentPipeline(t, 1)
encoder := &countingOrderedOutput{}
prepared.output = encoder
ctx, cancel := context.WithCancel(context.Background())
_, err := New().Run(ctx, RunInput{
Prepared: prepared,
RawInput: []byte("input"),
Debug: &cancelingDebugRecorder{cancel: cancel, onName: "output/input.json"},
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context.Canceled", err)
}
if got := encoder.calls.Load(); got != 0 {
t.Fatalf("output Encode calls = %d, want none", got)
}
}
func TestRunnerDiscardsOutputReturnedAfterCancellation(t *testing.T) {
prepared := preparedConcurrentPipeline(t, 1)
ctx, cancel := context.WithCancel(context.Background())
encoder := &cancelingOutputEncoder{OutputEncoder: prepared.output, cancel: cancel}
prepared.output = encoder
output, err := New().Run(ctx, RunInput{Prepared: prepared, RawInput: []byte("input")})
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context.Canceled", err)
}
if got := encoder.calls.Load(); got != 1 {
t.Fatalf("output Encode calls = %d, want one", got)
}
if len(output.OutputFiles) != 0 {
t.Fatalf("output files = %#v, want none", output.OutputFiles)
}
for _, warning := range output.Warnings {
if warning.ReasonCode == "returned-after-cancel" {
t.Fatalf("output warnings include encoder warning after cancellation")
}
}
}
func TestRunnerFailsGeneratedHandoffBeforeConsumerExtraction(t *testing.T) {
prepared := preparedConcurrentPipeline(t, 1)
input := &countingInputAdapter{InputAdapter: prepared.input}

View File

@@ -10,6 +10,7 @@ import (
"sync"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
@@ -253,6 +254,9 @@ func (e *laneEngine) startContinuationWorkers() {
}
}
// collect is the sole owner of pending, launch, and completion accounting. It
// must drain closed extract results and one completion for every launched
// continuation, even after cancellation.
func (e *laneEngine) collect() {
resultChannel := (<-chan extractJobResult)(e.results)
for resultChannel != nil || len(e.pending) > 0 || e.completed < e.launched {
@@ -406,20 +410,20 @@ func (r *Runner) runExtractJob(ctx context.Context, input RunInput, doc *source.
var accepted erasedExtractArtifact
var serialized CheckpointArtifact
var acceptedWarnings []contracts.Warning
ok, rejection, err := runWithRetry(ctx, lane.Extract.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) {
retryResult, err := runWithRetry(ctx, lane.Extract.Retries, func(attempt int) (retryAttemptResult, error) {
started := time.Now().UTC()
attemptPath := path.Join("extract", debugPathComponent(lane.ID), fmt.Sprintf("chunk-%06d", chunk.Index+1), fmt.Sprintf("attempt-%02d", attempt))
attemptPath := path.Join("extract", fileio.EncodePathComponent(lane.ID), fmt.Sprintf("chunk-%06d", chunk.Index+1), fmt.Sprintf("attempt-%02d", attempt))
attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath)
terminal := newAttemptTerminalRecorder(input.Debug, attemptPath, "extract", llmScope, debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, Attempt: attempt, StartedAt: started})
requestMetadata, metadataErr := cloneMetadata(input.Metadata)
if metadataErr != nil {
return false, nil, terminal.record(nil, fmt.Errorf("clone extract request metadata: %w", metadataErr))
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone extract request metadata: %w", metadataErr))
}
extractReferences := operationReferenceSet(input, lane.ExtractReferences)
extracted, callErr := typed.extract(attemptCtx, typed.extractor, contracts.TypedExtractionRequest{Source: doc, Chunk: &chunk, SourceInput: chunkInputMaterial(sourceInput, chunk), SessionID: sessionID, References: CloneReferenceSet(extractReferences), LLMProfile: lane.Extract.LLMProfile, Metadata: requestMetadata})
if callErr != nil {
attemptErr := fmt.Errorf("extract lane %q chunk %q with extractor %q: %w", lane.ID, chunk.ID, lane.Extract.Module, callErr)
return false, nil, terminal.record(nil, attemptErr)
return retryAttemptResult{}, terminal.record(nil, attemptErr)
}
artifact := erasedExtractArtifact{LaneID: lane.ID, ExtractorKey: lane.Extract.Module, SourceID: doc.ID, ChunkID: chunk.ID, ChunkIndex: chunk.Index, ChunkRef: chunk.Ref, Value: extracted.Value}
attemptWarnings := cloneWarnings(extracted.Warnings)
@@ -427,31 +431,38 @@ func (r *Runner) runExtractJob(ctx context.Context, input RunInput, doc *source.
if encodeErr != nil {
attemptErr := fmt.Errorf("serialize extract candidate for lane %q chunk %q: %w", lane.ID, chunk.ID, encodeErr)
payload := map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}
return false, nil, terminal.record(payload, attemptErr)
return retryAttemptResult{}, terminal.record(payload, attemptErr)
}
serializedCandidate.ChunkID, serializedCandidate.ChunkIndex, serializedCandidate.ChunkRef = artifact.ChunkID, artifact.ChunkIndex, artifact.ChunkRef
warnings, rejected, validateErr := r.validateTypedArtifact(attemptCtx, typed.codec, typedValidationTarget{stage: StageExtract, stepID: input.stepID, laneID: lane.ID, moduleKey: lane.Extract.Module, source: doc, sourceID: doc.ID, sourceInput: chunkInputMaterial(sourceInput, chunk), sessionID: sessionID, references: extractReferences, metadata: input.Metadata, chunk: &chunk, ref: chunk.Ref, value: extracted.Value, candidate: &serializedCandidate}, state.prepared.extractValidators, attempt, input.Debug)
attemptWarnings = append(attemptWarnings, warnings...)
payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected)}
if validateErr != nil || rejected != nil {
return false, rejected, terminal.record(payload, validateErr)
if validateErr != nil {
return retryAttemptResult{}, terminal.record(payload, validateErr)
}
if rejected != nil {
if debugErr := terminal.record(payload, nil); debugErr != nil {
return retryAttemptResult{}, debugErr
}
return retryAttemptResult{rejection: rejected, warnings: attemptWarnings}, nil
}
stored, encodeErr := checkpointArtifact(typed.codec, artifact.LaneID, artifact.ExtractorKey, artifact.SourceID, artifact.Value)
if encodeErr != nil {
attemptErr := fmt.Errorf("serialize accepted extract output for lane %q chunk %q: %w", lane.ID, chunk.ID, encodeErr)
return false, nil, terminal.record(payload, attemptErr)
return retryAttemptResult{}, terminal.record(payload, attemptErr)
}
stored.ChunkID, stored.ChunkIndex, stored.ChunkRef = artifact.ChunkID, artifact.ChunkIndex, artifact.ChunkRef
accepted, serialized = artifact, stored
acceptedWarnings = attemptWarnings
if debugErr := terminal.record(payload, nil); debugErr != nil {
return false, nil, debugErr
return retryAttemptResult{}, debugErr
}
return true, nil, nil
return retryAttemptResult{accepted: true}, nil
})
result.err = err
if err == nil && !ok {
result.rejected = rejection
if err == nil && !retryResult.accepted {
result.rejected = retryResult.rejection
result.warnings = cloneWarnings(retryResult.warnings)
return result
}
result.value, result.serialized, result.warnings = accepted, serialized, acceptedWarnings
@@ -469,6 +480,7 @@ func finalizeLaneExtract(checkpoints CheckpointRecorder, stepID string, state *l
result := state.results[index]
if result.rejected != nil {
state.rejected = append(state.rejected, *result.rejected)
state.warnings = append(state.warnings, result.warnings...)
continue
}
state.values = append(state.values, result.value)
@@ -498,10 +510,10 @@ func (r *Runner) continueLane(ctx context.Context, input RunInput, checkpoints C
}
local.Warnings = append(local.Warnings, cloneWarnings(results.warnings)...)
local.Rejected = append(local.Rejected, cloneRejectedOutputs(results.rejected)...)
if err := writeDebugTimed(input.Debug, path.Join("extract", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": results.decision.Reused, "decision": results.decision, "source": debugSourceDocumentEnvelope(doc), "chunks": debugSourceChunkEnvelopes(chunks), "options": redactSensitiveMap(lane.Extract.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
if err := writeDebugTimed(input.Debug, path.Join("extract", fileio.EncodePathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": results.decision.Reused, "decision": results.decision, "source": debugSourceDocumentEnvelope(doc), "chunks": debugSourceChunkEnvelopes(chunks), "options": redactSensitiveMap(lane.Extract.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
return local, &laneRunError{stage: StageExtract, err: err}
}
if err := writeDebugTimed(input.Debug, path.Join("extract", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": results.decision.Reused, "outputs": debugCheckpointArtifacts(results.serialized), "rejected": debugRejectedOutputEnvelopes(results.rejected), "warnings": debugWarningEnvelopes(results.warnings)}}); err != nil {
if err := writeDebugTimed(input.Debug, path.Join("extract", fileio.EncodePathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": results.decision.Reused, "outputs": debugCheckpointArtifacts(results.serialized), "rejected": debugRejectedOutputEnvelopes(results.rejected), "warnings": debugWarningEnvelopes(results.warnings)}}); err != nil {
return local, &laneRunError{stage: StageExtract, err: err}
}
if len(results.accepted) == 0 {

View File

@@ -0,0 +1,142 @@
package pipeline
import (
"context"
"fmt"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
type warningChunker struct {
key string
plan source.ChunkPlan
calls int
}
func (c *warningChunker) Key() string { return c.key }
func (*warningChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (c *warningChunker) Plan(context.Context, contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
c.calls++
return contracts.ChunkPlanResult{
Plan: source.CloneChunkPlan(c.plan),
Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", c.calls), ReasonCode: "operation", Message: "operation warning"}},
}, nil
}
type chunkValidationFunc struct {
name string
validate func(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error)
}
func (v chunkValidationFunc) Name() string { return v.name }
func (chunkValidationFunc) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (v chunkValidationFunc) Validate(ctx context.Context, request contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
return v.validate(ctx, request)
}
func TestRunnerPromotesOnlyTerminalRejectionWarnings(t *testing.T) {
for _, target := range []ModuleStage{StageChunk, StageExtract, StageMerge, StageNormalize} {
t.Run(string(target), func(t *testing.T) {
prepared := preparedAttemptDebugPipeline(t)
lane := &prepared.Steps[0].lanes[0]
attempts := 0
first := func() contracts.ValidationResult {
return contracts.ValidationResult{Approved: true, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("validator-%d", attempts), ReasonCode: "validator", Message: "validator warning"}}}
}
reject := func() contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "rejected"}
}
debug := newCapturedDebugRecorder()
recorder := &extractCaptureRecorder{CheckpointRecorder: NoopCheckpointRecorder()}
switch target {
case StageChunk:
chunker := prepared.chunker.(*typedTestChunker)
prepared.chunker = &warningChunker{key: prepared.resolved.Chunk.Module, plan: source.CloneChunkPlan(chunker.plan)}
prepared.resolved.Chunk.Retries = 1
prepared.chunkValidators.validators = []preparedValidator{
{resolved: ResolvedValidator{Binding: Binding("warning-approval"), Target: ValidatorTargetChunk}, chunk: chunkValidationFunc{name: "warning-approval", validate: func(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
return first(), nil
}}},
{resolved: ResolvedValidator{Binding: Binding("warning-rejection"), Target: ValidatorTargetChunk}, chunk: chunkValidationFunc{name: "warning-rejection", validate: func(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
return reject(), nil
}}},
}
chunkerWithWarnings := prepared.chunker.(*warningChunker)
first = func() contracts.ValidationResult {
return contracts.ValidationResult{Approved: true, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("validator-%d", chunkerWithWarnings.calls), ReasonCode: "validator", Message: "validator warning"}}}
}
case StageExtract:
lane.resolved.Extract.Retries = 1
installExtractOperation(prepared, 0, func(context.Context, contracts.TypedExtractionRequest) (erasedTypedResult, error) {
attempts++
return erasedTypedResult{Value: codecNotes{Items: []string{"extract"}}, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", attempts), ReasonCode: "operation", Message: "operation warning"}}}, nil
})
lane.extractValidators.validators = rejectionWarningTypedValidators(first, reject)
case StageMerge:
lane.resolved.Merge.Retries = 1
lane.typed.merge = func(context.Context, any, contracts.TypedMergeRequest[any]) (erasedTypedResult, error) {
attempts++
return erasedTypedResult{Value: codecNotes{Items: []string{"merge"}}, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", attempts), ReasonCode: "operation", Message: "operation warning"}}}, nil
}
lane.mergeValidators.validators = rejectionWarningTypedValidators(first, reject)
case StageNormalize:
lane.resolved.Normalize.Retries = 1
lane.typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
attempts++
return erasedTypedResult{Value: codecNotes{Items: []string{"normalize"}}, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", attempts), ReasonCode: "operation", Message: "operation warning"}}}, nil
}
lane.normalizeValidators.validators = rejectionWarningTypedValidators(first, reject)
}
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Checkpoints: recorder, Debug: debug})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
wantScopes := []string{"operation-2", "validator-2"}
if got := rejectionWarningScopes(output.Warnings); !reflect.DeepEqual(got, wantScopes) {
t.Fatalf("published warning scopes = %#v, want %#v", got, wantScopes)
}
if len(output.Rejected) != 1 || output.Rejected[0].AttemptCount != 2 {
t.Fatalf("rejections = %#v, want final rejection after two attempts", output.Rejected)
}
if target == StageExtract && !reflect.DeepEqual(rejectionWarningScopes(recorder.checkpoint.Warnings), wantScopes) {
t.Fatalf("extract checkpoint warnings = %#v, want %#v", recorder.checkpoint.Warnings, wantScopes)
}
attemptPath := fmt.Sprintf("%s/notes/attempt-01.json", target)
if target == StageChunk {
attemptPath = "chunk/attempt-01.json"
} else if target == StageExtract {
attemptPath = "extract/notes/chunk-000001/attempt-01.json"
}
if !strings.Contains(string(debug.json[attemptPath]), "operation-1") || !strings.Contains(string(debug.json[attemptPath]), "validator-1") {
t.Fatalf("first attempt debug = %s, want discarded warnings", debug.json[attemptPath])
}
})
}
}
func rejectionWarningTypedValidators(first func() contracts.ValidationResult, reject func() contracts.ValidationResult) []preparedValidator {
return []preparedValidator{
{resolved: ResolvedValidator{Binding: Binding("warning-approval"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"}, typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) {
return first(), nil
}},
{resolved: ResolvedValidator{Binding: Binding("warning-rejection"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"}, typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) {
return reject(), nil
}},
}
}
func rejectionWarningScopes(warnings []contracts.Warning) []string {
scopes := make([]string, len(warnings))
for index := range warnings {
scopes[index] = warnings[index].Scope
}
return scopes
}

View File

@@ -329,7 +329,7 @@ func TestRunnerKeepsExtractModuleAndValidatorLLMCallsIsolated(t *testing.T) {
t.Fatalf("Run() error = %v, want nil", err)
}
module := debug.envelope(t, "extract/notes/chunk-000001/attempt-01.json")
validator := debug.envelope(t, "validate/extract/notes/typed~2fextract-notes/01-llm-check-attempt-01.json")
validator := debug.envelope(t, "validate/extract/notes/typed%2Fextract-notes/01-llm-check-attempt-01.json")
if len(module.LLMCalls) != 1 || !strings.Contains(module.LLMCalls[0].ResponsePath, "extract/notes/chunk-000001/attempt-01/") {
t.Fatalf("module LLM calls = %#v, want extract module call only", module.LLMCalls)
}

View File

@@ -8,8 +8,10 @@ import (
"errors"
"fmt"
"path"
"reflect"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
@@ -232,7 +234,7 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints
return stageResult, err
}
mergeDecision = mergeResolution.decision
if err := writeDebugTimed(input.Debug, path.Join("merge", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "decision": mergeDecision, "source": debugSourceDocumentEnvelope(doc), "extract_outputs": debugCheckpointArtifacts(extracts.serialized), "options": redactSensitiveMap(lane.Merge.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
if err := writeDebugTimed(input.Debug, path.Join("merge", fileio.EncodePathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "decision": mergeDecision, "source": debugSourceDocumentEnvelope(doc), "extract_outputs": debugCheckpointArtifacts(extracts.serialized), "options": redactSensitiveMap(lane.Merge.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
return stageResult, err
}
var merged erasedMergeArtifact
@@ -247,52 +249,59 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints
if err := checkpointMergeRunning(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps); err != nil {
return stageResult, err
}
ok, rejection, runErr := runWithRetry(ctx, lane.Merge.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) {
retryResult, runErr := runWithRetry(ctx, lane.Merge.Retries, func(attempt int) (retryAttemptResult, error) {
started := time.Now().UTC()
attemptPath := path.Join("merge", debugPathComponent(lane.ID), fmt.Sprintf("attempt-%02d", attempt))
attemptPath := path.Join("merge", fileio.EncodePathComponent(lane.ID), fmt.Sprintf("attempt-%02d", attempt))
attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath)
terminal := newAttemptTerminalRecorder(input.Debug, attemptPath, "merge", llmScope, debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, Attempt: attempt, StartedAt: started})
requestMetadata, metadataErr := cloneMetadata(input.Metadata)
if metadataErr != nil {
return false, nil, terminal.record(nil, fmt.Errorf("clone merge request metadata: %w", metadataErr))
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone merge request metadata: %w", metadataErr))
}
result, callErr := typed.merge(attemptCtx, typed.merger, contracts.TypedMergeRequest[any]{Source: doc, LaneID: lane.ID, ExtractOutputs: mergeInputs, SourceInput: sourceInput.Clone(), SessionID: sessionID, References: CloneReferenceSet(mergeReferences), LLMProfile: lane.Merge.LLMProfile, Metadata: requestMetadata})
if callErr != nil {
attemptErr := fmt.Errorf("merge lane %q with merger %q: %w", lane.ID, lane.Merge.Module, callErr)
return false, nil, terminal.record(nil, attemptErr)
return retryAttemptResult{}, terminal.record(nil, attemptErr)
}
candidate := erasedMergeArtifact{LaneID: lane.ID, MergerKey: lane.Merge.Module, SourceID: doc.ID, Value: result.Value}
attemptWarnings := cloneWarnings(result.Warnings)
serializedCandidate, encodeErr := serializeCandidateArtifact(typed.codec, candidate.LaneID, candidate.MergerKey, candidate.SourceID, candidate.Value)
if encodeErr != nil {
attemptErr := fmt.Errorf("serialize merge candidate for lane %q: %w", lane.ID, encodeErr)
return false, nil, terminal.record(map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}, attemptErr)
return retryAttemptResult{}, terminal.record(map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}, attemptErr)
}
warnings, rejected, validateErr := r.validateTypedArtifact(attemptCtx, typed.codec, typedValidationTarget{stage: StageMerge, stepID: input.stepID, laneID: lane.ID, moduleKey: lane.Merge.Module, source: doc, sourceID: doc.ID, sourceInput: sourceInput.Clone(), sessionID: sessionID, references: mergeReferences, metadata: input.Metadata, value: result.Value, candidate: &serializedCandidate}, prepared.mergeValidators, attempt, input.Debug)
attemptWarnings = append(attemptWarnings, warnings...)
payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected)}
if validateErr != nil || rejected != nil {
return false, rejected, terminal.record(payload, validateErr)
if validateErr != nil {
return retryAttemptResult{}, terminal.record(payload, validateErr)
}
if rejected != nil {
if debugErr := terminal.record(payload, nil); debugErr != nil {
return retryAttemptResult{}, debugErr
}
return retryAttemptResult{rejection: rejected, warnings: attemptWarnings}, nil
}
stored, encodeErr := checkpointArtifact(typed.codec, candidate.LaneID, candidate.MergerKey, candidate.SourceID, candidate.Value)
if encodeErr != nil {
attemptErr := fmt.Errorf("serialize accepted merge output for lane %q: %w", lane.ID, encodeErr)
return false, nil, terminal.record(payload, attemptErr)
return retryAttemptResult{}, terminal.record(payload, attemptErr)
}
if debugErr := terminal.record(payload, nil); debugErr != nil {
return false, nil, debugErr
return retryAttemptResult{}, debugErr
}
merged, serializedMerge = candidate, stored
mergeWarnings = attemptWarnings
return true, nil, nil
return retryAttemptResult{accepted: true}, nil
})
if runErr != nil {
_ = checkpointMergeFailed(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps, runErr)
return stageResult, runErr
}
if !ok {
output.Rejected = append(output.Rejected, *rejection)
if err := checkpointMergeRejected(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps, *rejection); err != nil {
if !retryResult.accepted {
output.Warnings = append(output.Warnings, retryResult.warnings...)
output.Rejected = append(output.Rejected, *retryResult.rejection)
if err := checkpointMergeRejected(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps, *retryResult.rejection); err != nil {
return stageResult, err
}
stageResult.terminal = true
@@ -303,7 +312,7 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints
return stageResult, err
}
}
if err := writeDebugTimed(input.Debug, path.Join("merge", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedMerge), "warnings": debugWarningEnvelopes(mergeWarnings)}}); err != nil {
if err := writeDebugTimed(input.Debug, path.Join("merge", fileio.EncodePathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedMerge), "warnings": debugWarningEnvelopes(mergeWarnings)}}); err != nil {
return stageResult, err
}
stageResult.artifact = merged
@@ -329,7 +338,7 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
return stageResult, err
}
normalizeDecision = normalizeResolution.decision
if err := writeDebugTimed(input.Debug, path.Join("normalize", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "decision": normalizeDecision, "source": debugSourceDocumentEnvelope(doc), "merge_output": debugCheckpointArtifact(serializedMerge), "options": redactSensitiveMap(lane.Normalize.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
if err := writeDebugTimed(input.Debug, path.Join("normalize", fileio.EncodePathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "decision": normalizeDecision, "source": debugSourceDocumentEnvelope(doc), "merge_output": debugCheckpointArtifact(serializedMerge), "options": redactSensitiveMap(lane.Normalize.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
return stageResult, err
}
var serializedNormalize CheckpointArtifact
@@ -342,30 +351,30 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
if err := checkpointNormalizeRunning(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps); err != nil {
return stageResult, err
}
ok, rejection, runErr := runWithRetry(ctx, lane.Normalize.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) {
retryResult, runErr := runWithRetry(ctx, lane.Normalize.Retries, func(attempt int) (retryAttemptResult, error) {
started := time.Now().UTC()
attemptPath := path.Join("normalize", debugPathComponent(lane.ID), fmt.Sprintf("attempt-%02d", attempt))
attemptPath := path.Join("normalize", fileio.EncodePathComponent(lane.ID), fmt.Sprintf("attempt-%02d", attempt))
attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath)
terminal := newAttemptTerminalRecorder(input.Debug, attemptPath, "normalize", llmScope, debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, Attempt: attempt, StartedAt: started})
requestMetadata, metadataErr := cloneMetadata(input.Metadata)
if metadataErr != nil {
return false, nil, terminal.record(nil, fmt.Errorf("clone normalize request metadata: %w", metadataErr))
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone normalize request metadata: %w", metadataErr))
}
result, callErr := typed.normalize(attemptCtx, typed.normalizer, contracts.TypedNormalizeRequest[any]{Source: doc, LaneID: lane.ID, MergeOutput: contracts.MergeArtifact[any]{LaneID: lane.ID, MergerKey: lane.Merge.Module, SourceID: doc.ID, Value: merged.Value}, SourceInput: sourceInput.Clone(), SessionID: sessionID, References: CloneReferenceSet(normalizeReferences), LLMProfile: lane.Normalize.LLMProfile, Metadata: requestMetadata})
if callErr != nil {
attemptErr := fmt.Errorf("normalize lane %q with normalizer %q: %w", lane.ID, lane.Normalize.Module, callErr)
return false, nil, terminal.record(nil, attemptErr)
return retryAttemptResult{}, terminal.record(nil, attemptErr)
}
attemptWarnings := cloneWarnings(result.Warnings)
serializedCandidate, encodeErr := serializeCandidateArtifact(typed.codec, lane.ID, lane.Normalize.Module, doc.ID, result.Value)
if encodeErr != nil {
attemptErr := fmt.Errorf("serialize normalize candidate for lane %q: %w", lane.ID, encodeErr)
return false, nil, terminal.record(map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}, attemptErr)
return retryAttemptResult{}, terminal.record(map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}, attemptErr)
}
var retryPayload map[string]any
if result.Retry != nil {
if err := validateNormalizeRetry(result.Retry); err != nil {
return false, nil, terminal.record(map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings)}, fmt.Errorf("normalize lane %q returned invalid retry directive: %w", lane.ID, err))
return retryAttemptResult{}, terminal.record(map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings)}, fmt.Errorf("normalize lane %q returned invalid retry directive: %w", lane.ID, err))
}
retryRemaining := attempt <= lane.Normalize.Retries
retryPayload = map[string]any{
@@ -376,7 +385,7 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
}
if retryRemaining {
payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "retry": retryPayload}
return false, nil, terminal.record(payload, nil)
return retryAttemptResult{}, terminal.record(payload, nil)
}
attemptWarnings = append(attemptWarnings, cloneWarnings(result.Retry.FallbackWarnings)...)
}
@@ -386,28 +395,35 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
if retryPayload != nil {
payload["retry"] = retryPayload
}
if validateErr != nil || rejected != nil {
return false, rejected, terminal.record(payload, validateErr)
if validateErr != nil {
return retryAttemptResult{}, terminal.record(payload, validateErr)
}
if rejected != nil {
if debugErr := terminal.record(payload, nil); debugErr != nil {
return retryAttemptResult{}, debugErr
}
return retryAttemptResult{rejection: rejected, warnings: attemptWarnings}, nil
}
stored, encodeErr := checkpointArtifact(typed.codec, lane.ID, lane.Normalize.Module, doc.ID, result.Value)
if encodeErr != nil {
attemptErr := fmt.Errorf("serialize accepted normalize output for lane %q: %w", lane.ID, encodeErr)
return false, nil, terminal.record(payload, attemptErr)
return retryAttemptResult{}, terminal.record(payload, attemptErr)
}
if debugErr := terminal.record(payload, nil); debugErr != nil {
return false, nil, debugErr
return retryAttemptResult{}, debugErr
}
serializedNormalize = stored
normalizeWarnings = attemptWarnings
return true, nil, nil
return retryAttemptResult{accepted: true}, nil
})
if runErr != nil {
_ = checkpointNormalizeFailed(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps, runErr)
return stageResult, runErr
}
if !ok {
output.Rejected = append(output.Rejected, *rejection)
if err := checkpointNormalizeRejected(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps, *rejection); err != nil {
if !retryResult.accepted {
output.Warnings = append(output.Warnings, retryResult.warnings...)
output.Rejected = append(output.Rejected, *retryResult.rejection)
if err := checkpointNormalizeRejected(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps, *retryResult.rejection); err != nil {
return stageResult, err
}
return stageResult, nil
@@ -417,7 +433,7 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
return stageResult, err
}
}
if err := writeDebugTimed(input.Debug, path.Join("normalize", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedNormalize), "warnings": debugWarningEnvelopes(normalizeWarnings)}}); err != nil {
if err := writeDebugTimed(input.Debug, path.Join("normalize", fileio.EncodePathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedNormalize), "warnings": debugWarningEnvelopes(normalizeWarnings)}}); err != nil {
return stageResult, err
}
stageResult.serialized = serializedNormalize
@@ -469,7 +485,7 @@ func (r *Runner) validateTypedArtifact(ctx context.Context, codec artifactCodecE
var result contracts.ValidationResult
var err error
started := time.Now().UTC()
attemptPath := path.Join("validate", debugPathComponent(string(target.stage)), debugPathComponent(target.laneID), debugPathComponent(target.moduleKey), fmt.Sprintf("%02d-%s-attempt-%02d", index+1, debugPathComponent(binding.Module), attempt))
attemptPath := path.Join("validate", fileio.EncodePathComponent(string(target.stage)), fileio.EncodePathComponent(target.laneID), fileio.EncodePathComponent(target.moduleKey), fmt.Sprintf("%02d-%s-attempt-%02d", index+1, fileio.EncodePathComponent(binding.Module), attempt))
validatorCtx, llmScope := withIsolatedDebugLLMScope(ctx, attemptPath)
requestTarget := target
requestTarget.sourceInput = target.sourceInput.Clone()
@@ -488,6 +504,12 @@ func (r *Runner) validateTypedArtifact(ctx context.Context, codec artifactCodecE
}
switch item.resolved.Target {
case ValidatorTargetTyped:
candidateValue, decodeErr := decodeTypedValidationCandidate(codec, *target.candidate)
if decodeErr != nil {
err = fmt.Errorf("decode %s candidate for typed validator %q: %w", target.stage, binding.Module, decodeErr)
break
}
requestTarget.value = candidateValue
requestTarget.llmProfile = binding.LLMProfile
result, err = item.typedValidate(validatorCtx, item.typed, requestTarget)
case ValidatorTargetSerialized:
@@ -547,3 +569,15 @@ func validationCandidateArtifact(codec artifactCodecEntry, target typedValidatio
}
return serializeCandidateArtifact(codec, target.laneID, target.moduleKey, target.sourceID, target.value)
}
func decodeTypedValidationCandidate(codec artifactCodecEntry, candidate CheckpointArtifact) (any, error) {
value, err := codec.decodeCandidate(candidate.Artifact.Content)
if err != nil {
return nil, err
}
actualType := reflect.TypeOf(value)
if actualType != codec.valueType {
return nil, newArtifactCodecTypeError("decode candidate", codec.spec.Kind, codec.valueType, actualType)
}
return value, nil
}

Some files were not shown because too many files have changed in this diff Show More