Compare commits

69 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
92e89076a2 Add an audit plan and staged audit sequence to identify opportunities for code quality improvement 2026-08-08 20:42:35 +00:00
d9b87347b8 Simplify contextual entity grounding 2026-08-08 15:47:41 +00:00
20397ef710 Document deterministic entity identity resolution 2026-08-08 15:08:57 +00:00
fc449863f2 Use contextual descriptors for entity reconciliation 2026-08-08 15:05:35 +00:00
51d62de1f3 Ground location occurrences with contextual selectors 2026-08-08 14:56:43 +00:00
fc76805075 Add contextual location grounding 2026-08-08 14:49:08 +00:00
8e680cf96e Ground item occurrences by canonical names 2026-08-08 14:43:04 +00:00
ece1bca460 Ground NPC occurrences by canonical names 2026-08-08 14:37:54 +00:00
258 changed files with 12259 additions and 3577 deletions

View File

@@ -1,24 +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": {"type": "string"}
},
"canonical": {"type": "string"}
}
}
}
}
}

View File

@@ -3,10 +3,10 @@ in party possession established by the transcript. This is an occurrence history
not an inventory or ledger: do not calculate balances, resolve item identity not an inventory or ledger: do not calculate balances, resolve item identity
across records, or infer ownership that the transcript does not establish. across records, or infer ownership that the transcript does not establish.
For every occurrence, copy the exact `item_id` and `name` pair from the supplied For every occurrence, use the supplied canonical item `name`. Record a stated
item registry. Record a stated quantity as an integer and leave it null when the transcript quantity as an integer and leave it null when the transcript does not state
does not state one. Use a concise observed item name and preserve the stated one. Preserve the stated currency denomination through the selected canonical
currency denomination. registry name.
Use `discovered` when the party learns of or encounters an item without Use `discovered` when the party learns of or encounters an item without
establishing possession. Use `acquired` when the party or a party member gains establishing possession. Use `acquired` when the party or a party member gains

View File

@@ -1,6 +1,6 @@
Use the supplied item registry only to ground each occurrence. Every record Use the supplied item registry only to ground each occurrence. Every record
must copy one registry item's exact `id` and exact `name`; do not invent, must use one registry item's canonical `name`; do not invent, rename, merge,
rename, merge, or infer registry items. The registry is not transcript or infer registry items. The registry is not transcript evidence: cite only the
evidence: cite only the current transcript chunk in `source_refs`. current transcript chunk in `source_refs`.
{{ input "item_registry" }} {{ input "item_registry" }}

View File

@@ -10,9 +10,8 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["item_id", "name", "kind", "quantity", "from", "to", "source_refs"], "required": ["name", "kind", "quantity", "from", "to", "source_refs"],
"properties": { "properties": {
"item_id": {"type": "string"},
"name": {"type": "string"}, "name": {"type": "string"},
"kind": {"type": "string"}, "kind": {"type": "string"},
"quantity": {"type": ["integer", "null"]}, "quantity": {"type": ["integer", "null"]},
@@ -23,10 +22,10 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["start_segment", "end_segment"], "required": ["start_unit_id", "end_unit_id"],
"properties": { "properties": {
"start_segment": {"type": "integer"}, "start_unit_id": {"type": "integer"},
"end_segment": {"type": "integer"} "end_unit_id": {"type": "integer"}
} }
} }
} }

View File

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

View File

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

View File

@@ -20,6 +20,11 @@ location only when the chunk's context supports that coreference. It must not
create a registry location, and registry content or provenance must never create a registry location, and registry content or provenance must never
replace current-chunk evidence. replace current-chunk evidence.
For every occurrence, return the exact selector from the location registry:
the canonical `name`, plus an empty `registry_refs` array for a unique name or
the complete ordered `registry_refs` array for a repeated name. Registry ranges
and context identify the location only; they are not occurrence evidence.
For overlapping support, visited outranks planned, recalled, and mentioned; For overlapping support, visited outranks planned, recalled, and mentioned;
planned outranks recalled and mentioned; recalled outranks mentioned. A passage planned outranks recalled and mentioned; recalled outranks mentioned. A passage
may produce multiple records when it independently establishes separate facts, may produce multiple records when it independently establishes separate facts,

View File

@@ -1,9 +1,11 @@
A normalized location registry is provided below for identity grounding. It may A contextual location registry is provided below for identity grounding. It may
be empty. Each record contains the exact location ID and canonical display name be empty. Every record supplies a canonical display name. A name that appears
to copy when the transcript establishes an occurrence of that place. once is selected with that name and an empty `registry_refs` array. A repeated
name is selected only by copying both its name and its complete, ordered
`registry_refs` array exactly as supplied.
Registry content is context, not occurrence evidence. Do not derive an Registry content is context, not occurrence evidence. Do not derive an
occurrence or a source range from the registry, and do not infer a location occurrence or `source_refs` range from the registry. Do not invent a location
that is absent from it. or selector that is absent from it.
{{ input "location_registry" }} {{ input "location_registry" }}

View File

@@ -10,10 +10,21 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["location_id", "name", "kind", "source_refs"], "required": ["name", "registry_refs", "kind", "source_refs"],
"properties": { "properties": {
"location_id": {"type": "string"},
"name": {"type": "string"}, "name": {"type": "string"},
"registry_refs": {
"type": "array",
"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}
}
}
},
"kind": {"enum": ["visited", "planned", "recalled", "mentioned"]}, "kind": {"enum": ["visited", "planned", "recalled", "mentioned"]},
"source_refs": { "source_refs": {
"type": "array", "type": "array",

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
Extract Dungeons & Dragons NPC occurrences from the supplied Extract Dungeons & Dragons NPC occurrences from the supplied
transcript. Include an occurrence only when the transcript establishes one transcript. Include an occurrence only when the transcript establishes one
supplied NPC, one occurrence kind, and a coherent passage supporting both. supplied NPC, one occurrence kind, and a coherent passage supporting both.
Use the exact `npc_id` and matching `name` pair from the supplied NPC registry; Use the supplied canonical NPC `name`; never invent or substitute a similar
never invent an ID or substitute a similar name. name. Cite current-transcript evidence for every occurrence.
Do not summarize, infer relationships, sentiment, factions, motives, aliases, Do not summarize, infer relationships, sentiment, factions, motives, aliases,
or persistent state. Do not identify player characters, anonymous groups, or or persistent state. Do not identify player characters, anonymous groups, or

View File

@@ -10,11 +10,8 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["npc_id", "name", "kind", "source_refs"], "required": ["name", "kind", "source_refs"],
"properties": { "properties": {
"npc_id": {
"type": "string"
},
"name": { "name": {
"type": "string" "type": "string"
}, },

View File

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

View File

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

View File

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

View File

@@ -1,6 +0,0 @@
Identify only well-supported duplicate groups among the supplied candidates.
Candidate keys are opaque identifiers. Copy each selected key exactly. A group
must contain at least two supplied keys, and its `canonical` key must be one of
its members. Do not create keys, records, names, source references, 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. Transcript units are the only evidence for extracted events and factual claims.
Every reported factual claim must be supported by cited transcript units. Use 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 integer `start_unit_id` and `end_unit_id` values from the transcript.
`source_id`; Notarius assigns the current source identity.
When supporting evidence is non-contiguous, use multiple narrow ranges rather When supporting evidence is non-contiguous, use multiple narrow ranges rather
than a broad range that bridges unrelated conversation. than a broad range that bridges unrelated conversation.

View File

@@ -1,7 +1,5 @@
You process Dungeons & Dragons gameplay transcripts. You process Dungeons & Dragons gameplay transcripts.
Rely only on the supplied inputs. They may contain transcription errors, 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.
repeated lines, incomplete sentences, and misheard proper nouns.
Return exactly one JSON object that conforms to the configured response schema, Return exactly one JSON object that conforms to the configured response schema, with no explanatory prose.
with no explanatory prose.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
# ADR-0012: Resolve opaque entity identifiers deterministically
**Status:** Accepted
**Date:** 2026-08-08
## Context
Entity IDs in durable Notarius artifacts are application-owned, deterministic
identifiers. They are useful to artifact consumers, but their hash-based form
does not help a model distinguish entities and would make the model reproduce
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-typed-interfaces-with-two-zone-data-model.md) and the distinction
between disambiguating references and source evidence in
[ADR-0009](0009-minimal-evidence-grounded-extraction-artifacts.md).
## Decision
Callers present a model with semantic selections: a canonical name when it is
unique in the request, or a contextual descriptor containing the name and
source coordinates when that context is needed to distinguish supplied
records. The model returns only those supplied selections. The caller resolves
each accepted selection against the request-local supplied records and attaches
the opaque application ID deterministically.
Source coordinates are permitted in a selection solely as identity context.
They neither establish an occurrence fact nor replace that occurrence's
current-transcript evidence. A selector must resolve exactly; unknown,
ambiguous, partial, reordered, or otherwise unsafe selections are not mapped.
Where an operation requires a complete grounded artifact, that failure rejects
the complete artifact rather than accepting a partially mapped result.
An explicitly scoped request-local short label is permitted only when a
contextual descriptor would be impractical and the caller can deterministically
map the label within that one request. Such a label is not a durable ID, must
not escape the request boundary, and requires a concrete justification in its
own module contract.
## Alternatives considered
- Ask the model to return durable IDs. This exposes opaque implementation
state, does not improve semantic disambiguation, and makes model output
depend on hash formatting.
- Select by name alone. This cannot safely distinguish same-name records.
- Make request-local labels durable identifiers. This would turn prompt
presentation into a public identity contract and create avoidable migration
pressure.
- Let the model invent identifiers or resolve ambiguity. This makes identity
assignment non-deterministic and weakens validation.
## Consequences
Durable integration contracts retain their exact ID/name pairs while models
operate on readable contextual selections. Calling modules must own selector
construction, exact resolution, ambiguity handling, and conversion into their
durable artifact type; PromptKit and its adapter remain transport-only.
Some ambiguous or invalid proposals are deliberately omitted, retried, or
rejected according to the caller's existing failure policy. Internal candidate
keys may support deterministic request-local mapping, but they are not
model-visible selectors or durable data. This adds local validation work while
keeping identity assignment auditable and stable.

View File

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

View File

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

View File

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

View File

@@ -24,12 +24,13 @@ An incompatible shape change requires a new schema version.
Both extraction and normalization require an `item_registry` reference bound to Both extraction and normalization require an `item_registry` reference bound to
an earlier normalized `dnd/item-registry` artifact. The registry is immutable an earlier normalized `dnd/item-registry` artifact. The registry is immutable
for an operation and contributes only its ordered `{id,name}` projection after for an operation and contributes names-only grounding after the shared evidence
the shared evidence message. It is never occurrence evidence. message. Notarius resolves the model's selected name into the unchanged exact
durable ID/name pair. It is never occurrence evidence.
Each occurrence must use one exact registry ID/name pair. An extraction response Each occurrence must use one exact registry ID/name pair. An extraction response
with an unknown ID or mismatched name is rejected as invalid model output; the with an unknown or ambiguous selected name is rejected as invalid model output;
configured pipeline may retry it and never accepts a partial artifact. the configured pipeline may retry it and never accepts a partial artifact.
Normalization and validation remain defense in depth for artifacts entering Normalization and validation remain defense in depth for artifacts entering
through other boundaries: normalization canonicalizes a recognized name by ID, through other boundaries: normalization canonicalizes a recognized name by ID,
preserves unknown values for the registry validator, and the registry validator preserves unknown values for the registry validator, and the registry validator

View File

@@ -85,9 +85,10 @@ for later artifacts.
`dnd/item-occurrences` requires one approved item registry through its `dnd/item-occurrences` requires one approved item registry through its
`item_registry` reference slot for both extraction and normalization. Its `item_registry` reference slot for both extraction and normalization. Its
consumer receives only an ordered, source-free `{id,name}` projection; the consumer receives names-only grounding; Notarius resolves the selected name
registrys source references are never occurrence evidence. Unknown IDs and into the unchanged exact durable ID/name pair. The registrys source references
mismatched pairs are rejected by the occurrence contract. See the are never occurrence evidence. Unknown or ambiguous selections are rejected by
the occurrence contract. See the
[item-occurrence artifact](dnd-item-occurrence-artifacts.md) for that strict [item-occurrence artifact](dnd-item-occurrence-artifacts.md) for that strict
wire contract, [Configuration](../config.md#d-d-reference-slots) for binding wire contract, [Configuration](../config.md#d-d-reference-slots) for binding
rules and validator selection, and the [JSON output contract](json-output.md) rules and validator selection, and the [JSON output contract](json-output.md)

View File

@@ -73,10 +73,12 @@ complete canonical evidence sequence.
Both extraction and normalization require exactly one `location_registry` reference of Both extraction and normalization require exactly one `location_registry` reference of
kind `dnd/location-registry`, media type `application/json`, and at most 1 MiB. The kind `dnd/location-registry`, media type `application/json`, and at most 1 MiB. The
registry provides identity grounding only: unknown IDs and mismatched ID/name registry provides identity grounding only. The model selects a supplied
pairs are rejected rather than guessed or reassigned. The current transcript is contextual name-and-registry-reference descriptor, and Notarius resolves it
the only evidence source for an occurrence; registry evidence and provenance into the exact durable ID/name pair. Unknown, partial, or ambiguous selections
never become occurrence evidence. are rejected rather than guessed or reassigned. The current transcript is the
only evidence source for an occurrence; registry evidence and provenance never
become occurrence evidence.
See [Configuration](../config.md#d-d-reference-slots) for the selectable slot See [Configuration](../config.md#d-d-reference-slots) for the selectable slot
and generated-handoff compatibility, [D&D module internals](../internal/dnd.md) and generated-handoff compatibility, [D&D module internals](../internal/dnd.md)

View File

@@ -83,9 +83,11 @@ not evidence for later artifacts.
## Consumers and publication ## Consumers and publication
`dnd/location-occurrences` requires one approved location registry through its `dnd/location-occurrences` requires one approved location registry through its
`location_registry` reference slot. Its prompt receives an ordered source-free `{id, `location_registry` reference slot. Its prompt receives contextual selectors
name}` projection and must not treat registry references as occurrence containing a canonical name and registry references; Notarius resolves a
evidence. See the [location-occurrence artifact](dnd-location-occurrence-artifacts.md) selection into the unchanged exact durable ID/name pair. Registry references
must not be treated as occurrence evidence. See the
[location-occurrence artifact](dnd-location-occurrence-artifacts.md)
for that contract, [Configuration](../config.md#references-and-ordered-handoffs) for that contract, [Configuration](../config.md#references-and-ordered-handoffs)
for binding rules, and the [JSON output contract](json-output.md) for for binding rules, and the [JSON output contract](json-output.md) for
publication. publication.

View File

@@ -67,10 +67,12 @@ for uncertain classification.
## Identity, evidence, and order ## Identity, evidence, and order
The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md) resolves The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md)
the exact `{npc_id, name}` pair. Unknown IDs and names that do not match their supplies names-only contextual grounding to the model. Notarius resolves the
ID are rejected; normalization does not repair names by similarity. Registry selected name and writes the exact `{npc_id, name}` pair. An unknown or
references are provenance only and never replace an occurrence's own evidence. ambiguous selection rejects the complete model result; normalization does not
repair names by similarity. Registry references are provenance only and never
replace an occurrence's own evidence.
The registry may include an identity established by a factual third-party The registry may include an identity established by a factual third-party
mention; that provenance alone does not create a `mentioned` occurrence. Each mention; that provenance alone does not create a `mentioned` occurrence. Each
occurrence remains a separately cited fact in the current transcript. occurrence remains a separately cited fact in the current transcript.

View File

@@ -82,9 +82,10 @@ with its own cited evidence and category.
This registry can ground actor or caster names in the [spell](dnd-spell-artifacts.md) This registry can ground actor or caster names in the [spell](dnd-spell-artifacts.md)
and [combat-turn](dnd-combat-turn-artifacts.md) artifacts. It is required to and [combat-turn](dnd-combat-turn-artifacts.md) artifacts. It is required to
resolve the canonical `name` in an [NPC occurrence](dnd-npc-occurrence-artifacts.md). resolve the canonical `name` in an [NPC occurrence](dnd-npc-occurrence-artifacts.md).
Occurrence consumers receive an ordered source-free `{id,name}` projection; Occurrence consumers receive names-only grounding; Notarius resolves the
spells, combat turns, and the [enemy-event artifact](dnd-enemy-event-artifacts.md) selected canonical name and writes the unchanged exact durable ID/name pair.
receive names-only grounding for actor or subject display. None of these Spells, combat turns, and the [enemy-event artifact](dnd-enemy-event-artifacts.md)
also receive names-only grounding for actor or subject display. None of these
projections supply later-artifact evidence. [Configuration](../config.md#d-d-reference-slots) projections supply later-artifact evidence. [Configuration](../config.md#d-d-reference-slots)
owns the `npc_registry` binding rules. owns the `npc_registry` binding rules.
The [JSON output contract](json-output.md) defines publication, and The [JSON output contract](json-output.md) defines publication, and

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 case, whitespace, and apostrophe normalization, so authors should avoid names
or aliases that normalize to another spell. 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 The overlay is a recognition aid only. The durable spell-artifact schema and
source-evidence rules are defined by the source-evidence rules are defined by the
[D&D spell artifact contract](dnd-spell-artifacts.md). [D&D spell artifact contract](dnd-spell-artifacts.md).

View File

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

View File

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

View File

@@ -84,13 +84,15 @@ replace it with a complete profile of the same ID from the configured PromptKit
source. Deployment profile selection is documented in source. Deployment profile selection is documented in
[Configuration](../config.md#promptkit-profiles). [Configuration](../config.md#promptkit-profiles).
The transcript assets have distinct consumers. Scene chunking consumes the The D&D transcript assets have distinct consumers. Scene chunking consumes the
complete-session `common-dnd-transcript-full.md`; extraction prompts consume complete-session `common-dnd-transcript-full.md`, while extraction prompts
the current-chunk `common-dnd-transcript-chunk.md`; and NPC, location, and item consume the current-chunk `common-dnd-transcript-chunk.md`. NPC, location, and
normalization consume `common-dnd-transcript-windows.md` alongside their item normalization instead mount the generic semantic-reconciliation
candidate collections. Player, party, glossary, and compatible campaign candidate and transcript-window presentation assets. Player, party, glossary,
references provide disambiguating context, not evidence. Reference material is and compatible campaign references provide disambiguating context only when
canonically ordered before rendering so equivalent inputs remain stable. 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 Extraction prompts render the common system and identity messages first, then
cached campaign references and the cached chunk transcript. Evidence policy and cached campaign references and the cached chunk transcript. Evidence policy and
@@ -100,10 +102,11 @@ reusable extraction prefix identical while preserving the lane-specific suffix.
Scene chunking intentionally uses a different order: system, cached campaign Scene chunking intentionally uses a different order: system, cached campaign
references, uncached module instructions, then the final ephemeral full references, uncached module instructions, then the final ephemeral full
transcript. Entity normalization also has its own order: system, uncached transcript. Entity normalization also has its own order: D&D system, mandatory
module instructions, ephemeral reconciliation policy, uncached candidates, and generic protocol, ephemeral domain semantic instructions, generic candidate
final ephemeral transcript windows. These orders and cache controls are prompt presentation, and final ephemeral generic transcript windows. These orders and
behavior; change them only through the owning manifest and prompt declaration. cache controls are prompt behavior; change them only through the owning
manifest and prompt declaration.
## Evidence, Candidates, And Normalization ## Evidence, Candidates, And Normalization
@@ -116,7 +119,8 @@ result.
Default chains keep responsibilities separate: structural validators assess the Default chains keep responsibilities separate: structural validators assess the
candidate, source-reference validators resolve cited ranges against the current 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 relatedness validators report advisory evidence concerns. The configured order
is documented in is documented in
[Configuration](../config.md#production-validator-keys-and-default-chains). [Configuration](../config.md#production-validator-keys-and-default-chains).
@@ -127,14 +131,51 @@ combine results from distinct scenes, so it intentionally does not apply that
rule. Configuration owns the exact validator key and chain position. rule. Configuration owns the exact validator key and chain position.
Normalizers are deterministic for spells, combat turns, item occurrences, NPC Normalizers are deterministic for spells, combat turns, item occurrences, NPC
occurrences, scene descriptions, enemy events, and location occurrences. They canonicalize display occurrences, scene descriptions, enemy events, and location occurrences. They
values and evidence, use source-document order for stable output, and issue canonicalize display values and evidence, use source-document order for stable
bounded warnings for changes or collapsed duplicates. The NPC and location output, and issue bounded warnings for changes or collapsed duplicates. NPC,
normalizers are intentional exceptions: each first produces a deterministic item, and location registry normalizers are intentional exceptions: each first
candidate set, then may use a bounded structured-LLM proposal to reconcile produces a deterministic candidate set, then may use a bounded structured-LLM
identity groups. Invalid or unusable proposals retain the deterministic result proposal to reconcile identity groups.
and surface retry or fallback diagnostics; the model does not directly replace
durable records. ## 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 ## Generated References And Grounding
@@ -144,11 +185,19 @@ producer provenance; consumers resolve the handed-off artifact into an
immutable, validated projection for each operation. External files are checked immutable, validated projection for each operation. External files are checked
during preparation, while generated artifacts are resolved at the handoff. during preparation, while generated artifacts are resolved at the handoff.
NPC, location, and item registries project ordered, source-free `{id, name}` NPC and item registry consumers receive names-only grounding. Location
pairs to their respective occurrence extractors and normalizers. Exact ID/name consumers receive a contextual selector containing the canonical name and the
matching preserves every identity the registry recognizes, including same-name registry references needed to distinguish same-name places. The calling module
locations with distinct source anchors. The NPC registry additionally supplies resolves those supplied selections locally and maps them into the unchanged
names-only actor grounding to spells, combat turns, and enemy events. durable ID/name pair; an unknown or ambiguous selection rejects the complete
occurrence result rather than accepting a partial mapping. The NPC registry
additionally supplies names-only actor grounding to spells, combat turns, and
enemy events.
Registry references establish a registry identity and may disambiguate a
selection, but never become occurrence evidence. Each occurrence keeps its own
current-transcript source references, even when it was grounded through the
same registry record.
Scene descriptions are eligibility-only projections: they retain current-chunk Scene descriptions are eligibility-only projections: they retain current-chunk
classification data, not scene prose or evidence, and exist to route combat classification data, not scene prose or evidence, and exist to route combat
extraction. Enemy-event extraction also projects combat turns to `actor` and extraction. Enemy-event extraction also projects combat turns to `actor` and
@@ -170,7 +219,7 @@ checkpoint fingerprint.
| Spells | May use a spell-catalog overlay and optional NPC grounding; the catalog validator supplies domain-specific semantic checks. | | Spells | May use a spell-catalog overlay and optional NPC grounding; the catalog validator supplies domain-specific semantic checks. |
| NPC registry | Establishes transcript-grounded NPC identities, including factual third-party mentions, without assigning occurrence categories. It does not consume an NPC registry, and its normalizer is the LLM-assisted reconciliation exception described above. | | NPC registry | Establishes transcript-grounded NPC identities, including factual third-party mentions, without assigning occurrence categories. It does not consume an NPC registry, and its normalizer is the LLM-assisted reconciliation exception described above. |
| Combat turns | Requires a scene-description artifact. It calls the LLM only for an exact `combat` classification; exact non-combat classifications return an accepted empty result, while missing or mismatched classifications return an empty result with a bounded warning. Optional NPC grounding never becomes evidence. | | Combat turns | Requires a scene-description artifact. It calls the LLM only for an exact `combat` classification; exact non-combat classifications return an accepted empty result, while missing or mismatched classifications return an empty result with a bounded warning. Optional NPC grounding never becomes evidence. |
| Item occurrences | Requires the normalized item registry for exact ID/name grounding at extraction and normalization. Campaign context may disambiguate, but the registry never becomes occurrence evidence. | | Item occurrences | Requires the normalized item registry for exact deterministic grounding at extraction and normalization. Campaign context may disambiguate, but the registry never becomes occurrence evidence. |
| Item registry | Produces source-grounded item types and unique designations. Its LLM-assisted reconciliation is proposal-only, preserves distinct currency denominations and item types, and does not create per-instance identities. | | Item registry | Produces source-grounded item types and unique designations. Its LLM-assisted reconciliation is proposal-only, preserves distinct currency denominations and item types, and does not create per-instance identities. |
| NPC occurrences | Requires the normalized NPC registry at extraction and normalization, using it for canonical actor grounding only. It separately emits cited current-transcript occurrence facts, including `mentioned`, rather than deriving them from registry provenance. | | NPC occurrences | Requires the normalized NPC registry at extraction and normalization, using it for canonical actor grounding only. It separately emits cited current-transcript occurrence facts, including `mentioned`, rather than deriving them from registry provenance. |
| Scene descriptions | Produces the classifications consumed by combat routing; it does not consume an NPC registry or provide evidence for combat artifacts. | | Scene descriptions | Produces the classifications consumed by combat routing; it does not consume an NPC registry or provide evidence for combat artifacts. |

View File

@@ -24,6 +24,9 @@ adapter does not own source evidence, artifact conversion, normalization, or
durable schemas. Those responsibilities remain with the module and its durable schemas. Those responsibilities remain with the module and its
[integration contract](../integrations/). [integration contract](../integrations/).
The calling module also resolves contextual entity selections and attaches any
application identity; PromptKit and this adapter do not own entity identity.
`PromptKitClient` validates the request target and prompt identity, maps each `PromptKitClient` validates the request target and prompt identity, maps each
named material to a PromptKit inline artifact while preserving its origin URI, named material to a PromptKit inline artifact while preserving its origin URI,
passes the supplied request session through to PromptKit's direct per-run passes the supplied request session through to PromptKit's direct per-run
@@ -99,7 +102,8 @@ because it changes scheduling rather than execution semantics.
Production construction creates one PromptKit client and wraps it in one Production construction creates one PromptKit client and wraps it in one
scheduled client. The scheduler has a fixed, positive permit limit, serves 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 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 The scheduled wrapper surrounds every `CompleteStructured` call, so concurrent
lanes, pipeline retries, and LLM-backed validators share the same provider-call lanes, pipeline retries, and LLM-backed validators share the same provider-call
@@ -138,12 +142,28 @@ arrangement and its data-only boundary are defined by
[ADR-0011](../adr/0011-centralize-llm-assets.md), rather than by this runtime [ADR-0011](../adr/0011-centralize-llm-assets.md), rather than by this runtime
guide. guide.
The generic registrar is the sole production registration owner for the
semantic-reconciliation default prompt and private response schema. The
domain-neutral reconciliation package also exposes only its mandatory protocol
and candidate/transcript presentation files for domain prompt manifests. D&D
registry normalizers mount those files while retaining ownership and hashing
of their D&D system message, semantic instructions, and complete prompt
declaration. The response schema is therefore registered once even though
several typed normalizers select it.
Mounted prompt assets determine a module's fingerprint. The fingerprint hashes Mounted prompt assets determine a module's fingerprint. The fingerprint hashes
only the module and shared files explicitly selected by its manifest, so an only the module and shared files explicitly selected by its manifest, so an
unrelated asset does not invalidate a checkpoint. Schema loaders validate JSON, unrelated asset does not invalidate a checkpoint. Schema loaders validate JSON,
attach identity and digest metadata, make defensive copies, and expose attach identity and digest metadata, make defensive copies, and expose
diagnostics without raw schema bytes. diagnostics without raw schema bytes.
Semantic-reconciliation normalizers extend this identity with the shared
response-schema digest, framework policy version, and complete limit-policy
digest. Their manifest metadata records the same content-free prompt, schema,
policy, and limit identities together with domain identity and normalization
policies. Request-local handles, source material, proposal content, and raw
asset bytes are not checkpoint metadata.
Private response schemas validate a model transport envelope. They are not the Private response schemas validate a model transport envelope. They are not the
durable artifact schema and should not be documented as an external wire durable artifact schema and should not be documented as an external wire
contract. Durable formats and compatibility rules remain in the contract. Durable formats and compatibility rules remain in the
@@ -176,7 +196,9 @@ structured-output validation. The adapter reports an empty result, validation
failure, empty structured body, or decode failure as failure, empty structured body, or decode failure as
`ErrInvalidStructuredOutput`, while retaining the returned raw bytes and debug `ErrInvalidStructuredOutput`, while retaining the returned raw bytes and debug
material when they exist. Provider failures remain operational errors rather 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 When PromptKit rejects backend admission before generation, the adapter maps
`promptkit.ErrCapacityExceeded` to `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 interpret surrounding context or publish files; the pipeline validates the
capability during preparation and the output boundary owns publication. See capability during preparation and the output boundary owns publication. See
the [Published Evidence Context contract](../integrations/evidence-context.md) the [Published Evidence Context contract](../integrations/evidence-context.md)
for the durable result. for the durable source-unit excerpt. Lane artifacts retain citation and lane
provenance; the framework does not add either to that published excerpt.
An artifact family is broader than a module: it owns the cohesive domain
feature across its artifact type, codec, stage modules, validators, prompt
policy, schemas, identity helpers, and reference projections. An extractor and
normalizer in one artifact family remain independently registered modules in
their respective pipeline stages. This ownership vocabulary does not create a
new registry or change the fixed pipeline.
## Production Composition ## Production Composition
Production composition is intentionally split by family: Production composition is intentionally split by family:
- The generic registrar provides the unit chunker, generic JSON validators, - The generic registrar provides the unit chunker, generic JSON validators,
and JSON output encoder. JSON output encoder, and shared semantic-reconciliation prompt and response
schema assets.
- The Seriatim registrar provides the transcript input adapter. Its external - The Seriatim registrar provides the transcript input adapter. Its external
input behavior is defined by the [Seriatim contract](../integrations/seriatim.md). input behavior is defined by the [Seriatim contract](../integrations/seriatim.md).
- The D&D registrar provides its codecs, extractors, mergers, normalizers, - The D&D registrar provides its codecs, extractors, mergers, normalizers,
@@ -60,6 +69,36 @@ The CLI owns the composition that invokes these registrars. A module package
may register its own family but must not assemble the CLI or make framework may register its own family but must not assemble the CLI or make framework
packages depend on production extensions. packages depend on production extensions.
## Semantic Reconciliation
`internal/framework/semanticreconcile` is a domain-neutral strategy used by a
typed normalize module; it is not itself a selectable stage module. A
source-backed artifact-family normalizer projects its deterministic records
into contextual candidates and owned typed record envelopes, supplies its
chosen prompt identity and resolved LLM profile, and constructs an engine with
explicit limits. The core filters invalid evidence, assigns contiguous
request-local integer handles, renders bounded candidate and transcript
materials, invokes the structured-completion boundary, and assesses the
returned duplicate groups into a stable non-overlapping plan.
The normalizer then applies that plan through a typed `ApplicationPolicy`. The
core preserves ungrouped records, contribution order, and provenance while the
artifact family owns group guards, field and evidence consolidation, durable
ID derivation, retry and fallback presentation, warnings, and postconditions.
Request-local handles do not enter the typed value or durable artifact. Fewer
than two eligible candidates skips model invocation; exceeding a candidate or
combined-material bound preserves the deterministic result under the family's
fallback policy. Provider, transport, cancellation, and context-construction
failures remain execution errors.
The core supplies a conservative generic prompt and the single private
response schema. A domain prompt may substitute its semantic instructions but
mounts the core-owned protocol and candidate/transcript presentation assets.
Prompt, schema, policy, and limit identities participate in manifest metadata
and checkpoint fingerprints. The generic registrar owns production
registration of those shared assets; a consuming domain registrar owns only
its domain prompt.
## Adding Or Changing A Module ## Adding Or Changing A Module
1. Choose the pipeline stage and the typed artifact boundary. Put external 1. Choose the pipeline stage and the typed artifact boundary. Put external

View File

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

View File

@@ -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 checks that each slot is declared by the selected module, resolves a file path
relative to the correct configuration or working-directory origin, reads relative to the correct configuration or working-directory origin, reads
UTF-8 text, verifies media type and size limits, and retains bounded UTF-8 text, verifies media type and size limits, and retains bounded
provenance. A generated-artifact selector remains declared but has no bytes provenance. For a positive slot limit, it reads at most the limit plus one byte
until its producing step completes. 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 Preparation is the construction boundary. It validates the resolved shape and
registry set, clones the resolved data, then constructs the input adapter, registry set, clones the resolved data, then constructs the input adapter,
chunker, stage-local validators, every typed lane, and output encoder with chunker, stage-local validators, every typed lane, and output encoder. Each
cloned options, references, and shared dependencies. It also collects stable registered builder receives its own cloned build request immediately before its
checkpoint fingerprints. Missing registrations, incompatible typed entries, module-owned code runs. Preparation also collects stable checkpoint
nil implementations, and constructor failures are reported before source fingerprints. Missing registrations, incompatible typed entries, nil
parsing or any stage operation begins. 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 An output encoder can opt into source-evidence publication through its output
policy. Preparation keeps the configured lane allowlist and active lanes 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 Every chunk, extract, merge, and normalize candidate passes its resolved
validator chain. Validators receive immutable canonical input appropriate to validator chain. Validators receive immutable canonical input appropriate to
their target: chunks, typed values, or serialized codec bytes. They may their target: chunks, codec-decoded typed candidates, or serialized codec
approve, approve with warnings, reject, or fail. A rejection is an ordinary bytes. Each typed validator receives a newly decoded value from the one
pipeline result; a validator error is a framework error. 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 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 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. they do not describe a current public state surface.
Ordered-step lane checkpoints include the step identity in their storage scope. 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 When a later lane consumes a generated artifact, its dependency fingerprints
include the producer's artifact kind, complete schema identity, media type, include the producer's artifact kind, complete schema identity, media type,
canonical content digest, and size. Ordinary resume compares those fingerprints 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 [evidence context](integrations/evidence-context.md) contains source-unit text
and metadata. It is not a cache or debug artifact: retain it with the output and metadata. It is not a cache or debug artifact: retain it with the output
bundle only for as long as consumers need it, and apply source-content access bundle only for as long as consumers need it, and apply source-content access
controls to the entire bundle. Selected lanes may collectively cite most of a controls to the entire bundle. Its selected source-unit excerpt may include
transcript, so a broad allowlist can make the evidence artifact nearly as every source unit once when coverage is broad or its configured window is
sensitive and large as the source itself. large, so do not assume a byte or token reduction or reduced sensitivity.
## Chunk-Plan Cache ## Chunk-Plan Cache

View File

@@ -24,6 +24,12 @@ DAGs or a general workflow language. Every stage remains explicit; general
chunking, merging, or normalization behavior must not be hidden inside an chunking, merging, or normalization behavior must not be hidden inside an
extractor. extractor.
A stage module is one configured implementation of one pipeline stage. An
artifact family is the cohesive domain feature that owns an artifact across
the explicit stages and supporting codecs, validators, prompts, identity
rules, and reference projections. Artifact-family ownership does not combine
stages or alter the fixed pipeline.
Input and chunking are pipeline-wide. Each selected artifact lane owns its Input and chunking are pipeline-wide. Each selected artifact lane owns its
extract, merge, and normalize stages, and the output stage aggregates the run's extract, merge, and normalize stages, and the output stage aggregates the run's
lane outcomes. lane outcomes.
@@ -39,6 +45,12 @@ implementations. Domain-neutral model and framework layers provide reusable
policy, contracts, and orchestration. Concrete input, pipeline, output, and policy, contracts, and orchestration. Concrete input, pipeline, output, and
validation extensions depend inward on those generic layers. validation extensions depend inward on those generic layers.
Semantic reconciliation is one such domain-neutral framework mechanism. It
prepares bounded source context, invokes a shared model-judgment protocol,
validates proposals, and applies safe plans through typed policies supplied by
the consuming artifact family. It does not own domain identity, durable IDs,
warning semantics, or artifact construction rules.
Generic layers must not depend on production extensions. Concrete extensions Generic layers must not depend on production extensions. Concrete extensions
must not compose the application or take ownership of process behavior. The must not compose the application or take ownership of process behavior. The
current packages implementing these layers are inventoried in current packages implementing these layers are inventoried in
@@ -183,6 +195,17 @@ The caller of the LLM owns prompt selection, prompt inputs, response schema,
and interpretation of structured output. Provider adapters do not own source- and interpretation of structured output. Provider adapters do not own source-
or domain-specific prompt logic. 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. 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 LLM calls and other external operations accept cancellation and respect
timeouts. Concurrency control belongs in shared runtime plumbing rather than in timeouts. Concurrency control belongs in shared runtime plumbing rather than in
individual modules. individual modules.

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -189,10 +189,18 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
} }
evidence := readProductionJSON[evidencecontext.Document](t, filepath.Join(runRoot, "evidence-context.json")) evidence := readProductionJSON[evidencecontext.Document](t, filepath.Join(runRoot, "evidence-context.json"))
for _, laneID := range []string{"enemy-events", "npc-registry", "npc-occurrences", "item-registry", "item-occurrences", "location-registry", "location-occurrences"} { if len(evidence) == 0 {
if !containsString(evidence.SelectedLanes, laneID) || !evidenceHasLane(evidence, laneID) { t.Fatalf("evidence context = %#v, want selected source-unit evidence", evidence)
t.Fatalf("evidence context = %#v, want direct %s evidence", evidence, laneID) }
seenEvidenceUnits := make(map[int]struct{}, len(evidence))
for _, unit := range evidence {
if unit.Ref.SourceID != "session-ravenfall" || unit.Ref.StartUnitID != unit.ID || unit.Ref.EndUnitID != unit.ID {
t.Fatalf("evidence unit = %#v, want unchanged source-unit self-reference", unit)
} }
if _, exists := seenEvidenceUnits[unit.ID]; exists {
t.Fatalf("evidence context = %#v, want each source unit once", evidence)
}
seenEvidenceUnits[unit.ID] = struct{}{}
} }
requests := client.requestsFor(enemyevents.PromptID) requests := client.requestsFor(enemyevents.PromptID)
@@ -219,14 +227,15 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
} }
for _, request := range locationRequests { for _, request := range locationRequests {
registryInput := request.Inputs["location_registry"] registryInput := request.Inputs["location_registry"]
if !strings.Contains(string(registryInput.Content), "Moon Gate") || !strings.Contains(string(registryInput.Content), `"id"`) || strings.Contains(string(registryInput.Content), "source_refs") { if !strings.Contains(string(registryInput.Content), "Moon Gate") || !strings.Contains(string(registryInput.Content), "registry_refs") || strings.Contains(string(registryInput.Content), `"id"`) || strings.Contains(string(registryInput.Content), "source_refs") {
t.Fatalf("location occurrence registry input = %q, want source-free ID grounding", registryInput.Content) t.Fatalf("location occurrence registry input = %q, want contextual selector grounding", registryInput.Content)
} }
} }
for _, test := range []struct { for _, test := range []struct {
promptID string promptID string
slot string slot string
name string name string
requiresIDs bool
}{ }{
{promptID: npcoccurrences.PromptID, slot: "npc_registry", name: "Kesh"}, {promptID: npcoccurrences.PromptID, slot: "npc_registry", name: "Kesh"},
{promptID: itemoccurrences.PromptID, slot: "item_registry", name: "Moonblade"}, {promptID: itemoccurrences.PromptID, slot: "item_registry", name: "Moonblade"},
@@ -237,8 +246,9 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
} }
for _, request := range requests { for _, request := range requests {
registryInput := request.Inputs[test.slot] registryInput := request.Inputs[test.slot]
if !strings.Contains(string(registryInput.Content), test.name) || !strings.Contains(string(registryInput.Content), `"id"`) || strings.Contains(string(registryInput.Content), "source_refs") { hasID := strings.Contains(string(registryInput.Content), `"id"`)
t.Fatalf("%s registry input = %q, want source-free ID grounding", test.promptID, registryInput.Content) if !strings.Contains(string(registryInput.Content), test.name) || hasID != test.requiresIDs || strings.Contains(string(registryInput.Content), "source_refs") {
t.Fatalf("%s registry input = %q, want source-free configured grounding", test.promptID, registryInput.Content)
} }
} }
} }
@@ -319,16 +329,16 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
} else { } else {
var registry struct { var registry struct {
Items []struct { Items []struct {
ID string `json:"id"` Name string `json:"name"`
} `json:"items"` } `json:"items"`
} }
if err := json.Unmarshal(request.Inputs["item_registry"].Content, &registry); err != nil { if err := json.Unmarshal(request.Inputs["item_registry"].Content, &registry); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated item registry: %w", err) return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated item registry: %w", err)
} }
if len(registry.Items) != 1 { 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)) return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated item registry has %d items, want 1", len(registry.Items))
} }
content = []byte(fmt.Sprintf(`{"occurrences":[{"item_id":%q,"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":5,"end_segment":5}]}]}`, registry.Items[0].ID)) 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: case combat.PromptID:
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`) content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`)
@@ -336,23 +346,27 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
if combatScene { if combatScene {
var registry struct { var registry struct {
NPCs []struct { NPCs []struct {
ID string `json:"id"` Name string `json:"name"`
} `json:"npcs"` } `json:"npcs"`
} }
if err := json.Unmarshal(request.Inputs["npc_registry"].Content, &registry); err != nil { if err := json.Unmarshal(request.Inputs["npc_registry"].Content, &registry); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated NPC registry: %w", err) return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated NPC registry: %w", err)
} }
if len(registry.NPCs) == 0 { if len(registry.NPCs) == 0 || registry.NPCs[0].Name != "Kesh" {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated NPC registry has no NPCs") return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated NPC registry has no NPCs")
} }
content = []byte(fmt.Sprintf(`{"occurrences":[{"npc_id":%q,"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`, registry.NPCs[0].ID)) content = []byte(`{"occurrences":[{"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`)
} else { } else {
content = []byte(`{"occurrences":[]}`) content = []byte(`{"occurrences":[]}`)
} }
case locationoccurrences.PromptID: case locationoccurrences.PromptID:
var registry struct { var registry struct {
Locations []struct { Locations []struct {
ID string `json:"id"` Name string `json:"name"`
RegistryRefs []struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
} `json:"registry_refs"`
} `json:"locations"` } `json:"locations"`
} }
if err := json.Unmarshal(request.Inputs["location_registry"].Content, &registry); err != nil { if err := json.Unmarshal(request.Inputs["location_registry"].Content, &registry); err != nil {
@@ -362,14 +376,18 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has no locations") return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has no locations")
} }
unitID := 1 unitID := 1
locationID := registry.Locations[0].ID location := registry.Locations[0]
if combatScene { if combatScene {
unitID = 7 unitID = 7
if len(registry.Locations) > 1 { if len(registry.Locations) > 1 {
locationID = registry.Locations[1].ID location = registry.Locations[1]
} }
} }
content = []byte(fmt.Sprintf(`{"occurrences":[{"location_id":%q,"name":"Moon Gate","kind":"visited","source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, locationID, unitID, unitID)) registryRefs, err := json.Marshal(location.RegistryRefs)
if err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("encode location selector: %w", err)
}
content = []byte(fmt.Sprintf(`{"occurrences":[{"name":%q,"registry_refs":%s,"kind":"visited","source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, location.Name, registryRefs, unitID, unitID))
case enemyevents.PromptID: case enemyevents.PromptID:
content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`) content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`)
default: default:
@@ -405,17 +423,6 @@ func containsString(values []string, want string) bool {
return false return false
} }
func evidenceHasLane(value evidencecontext.Document, laneID string) bool {
for _, context := range value.Contexts {
for _, reference := range context.EvidenceRefs {
if reference.LaneID == laneID {
return true
}
}
}
return false
}
func generatedReferenceBinding(bindings []pipeline.ReferenceBinding, slotName string) (pipeline.ReferenceBinding, bool) { func generatedReferenceBinding(bindings []pipeline.ReferenceBinding, slotName string) (pipeline.ReferenceBinding, bool) {
for _, binding := range bindings { for _, binding := range bindings {
if binding.SlotName == slotName && binding.Artifact != nil { if binding.SlotName == slotName && binding.Artifact != nil {

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config" "gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle" "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/checkpoint"
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkplan" "gitea.maximumdirect.net/eric/notarius/internal/framework/chunkplan"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "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") machineOutput := fs.Bool("json", false, "write the successful run result as JSON")
debug := fs.Bool("debug", false, "write a debug bundle") debug := fs.Bool("debug", false, "write a debug bundle")
debugDir := fs.String("debug-dir", "", "debug bundle directory") 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"} reasoningEffort := singleValueFlag{name: "--reasoning-effort"}
clearReasoningEffort := fs.Bool("clear-reasoning-effort", false, "clear the LLM profile reasoning effort") clearReasoningEffort := fs.Bool("clear-reasoning-effort", false, "clear the LLM profile reasoning effort")
resume := fs.Bool("resume", false, "reuse compatible recorded checkpoints") 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{} referenceFlags := stringListFlag{}
withoutReferenceFlags := stringListFlag{} withoutReferenceFlags := stringListFlag{}
fs.Var(&requestedSessionID, "session-id", "prompt session identifier") 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(&reasoningEffort, "reasoning-effort", "reasoning effort override")
fs.Var(&chunkCache, "chunk_cache", "chunk plan cache mode: auto, bypass, or refresh") 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") 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") fmt.Fprintln(stderr, "notarius: --session-id must not be empty")
return 2 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 { if reasoningEffort.set && *clearReasoningEffort {
fmt.Fprintln(stderr, "notarius: --reasoning-effort cannot be combined with --clear-reasoning-effort") fmt.Fprintln(stderr, "notarius: --reasoning-effort cannot be combined with --clear-reasoning-effort")
return 2 return 2
@@ -338,7 +344,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
PipelineID: pipelineID, PipelineID: pipelineID,
Only: only, Only: only,
Catalog: catalog, Catalog: catalog,
LLMProfileOverride: *llmProfile, LLMProfileOverride: strings.TrimSpace(llmProfile.value),
ReferenceOverrides: referenceOverrides, ReferenceOverrides: referenceOverrides,
ReferenceUnbinds: referenceUnbinds, ReferenceUnbinds: referenceUnbinds,
}) })
@@ -426,7 +432,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
if err != nil { if err != nil {
return failPipelineCommand(stderr, commandState, terminalWriter, err) 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 { if err != nil {
return failPipelineCommand(stderr, commandState, terminalWriter, err) return failPipelineCommand(stderr, commandState, terminalWriter, err)
} }
@@ -752,17 +758,10 @@ func configSource(configPath string) string {
} }
func writeOutputFiles(runOutputDir string, files []contracts.OutputFile) error { 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 { for _, file := range files {
targetPath, err := outputFilePath(runOutputDir, file.Name) if _, err := outputFilePath(runOutputDir, file.Name); err != nil {
if err != nil {
return err return err
} }
targets = append(targets, outputTarget{path: targetPath, file: file})
} }
outputParent := filepath.Dir(runOutputDir) 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) return fmt.Errorf("create output run directory %q: %w", runOutputDir, err)
} }
for _, target := range targets { for _, file := range files {
if err := os.MkdirAll(filepath.Dir(target.path), 0o755); err != nil { if err := fileio.WriteBytes(runOutputDir, file.Name, file.Bytes, 0o755, 0o644); err != nil {
return fmt.Errorf("create output directory %q: %w", filepath.Dir(target.path), err) return fmt.Errorf("write output file %q: %w", file.Name, err)
}
if err := writeFileAtomic(target.path, target.file.Bytes, 0o644); err != nil {
return fmt.Errorf("write output file %q: %w", target.file.Name, err)
} }
} }
return nil return nil
@@ -823,38 +819,6 @@ func outputFilePath(runOutputDir, logicalName string) (string, error) {
return target, nil 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 { func reorderRunArgs(args []string) []string {
var flags []string var flags []string
var positionals []string var positionals []string

View File

@@ -43,6 +43,12 @@ func TestRunControlsRejectSyntaxWithoutAllocatingState(t *testing.T) {
{name: "blank session ID", args: func(roots stateTestRoots) []string { {name: "blank session ID", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--session-id", ""} 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 { {name: "multiple pipeline IDs", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "extra", "--config", roots.config, "--input", roots.input} return []string{"run", "sample", "extra", "--config", roots.config, "--input", roots.input}
}}, }},
@@ -253,7 +259,7 @@ func TestRunLLMProfileOverrideAndValidationUseInjectedBoundaries(t *testing.T) {
return nil, nil, nil return nil, nil, nil
} }
var stdout, stderr bytes.Buffer 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 { if code != 0 || stderr.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) 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 { if err := writeOutputFiles(runPath, []contracts.OutputFile{{Name: "nested/result.json", Bytes: []byte("result")}}); err != nil {
t.Fatal(err) 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" { if err != nil || string(data) != "result" {
t.Fatalf("nested output = %q, %v", data, err) 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) { func TestWriteOutputFilesRejectsUnsafeNamesBeforeAllocatingRunDirectory(t *testing.T) {
@@ -75,7 +94,7 @@ func TestWriteOutputFilesRetainsNewPartialDirectoryAndPreservesSibling(t *testin
{Name: "blocked", Bytes: []byte("partial output")}, {Name: "blocked", Bytes: []byte("partial output")},
{Name: "blocked/nested.json", Bytes: []byte("unreachable")}, {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) 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" { 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)) t.Fatalf("spell requests = %d, want one", len(requests))
} }
catalogInput, ok := requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot] catalogInput, ok := requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot]
if !ok || !strings.Contains(string(catalogInput.Content), "Aegis of Emberfall") || strings.Contains(string(catalogInput.Content), "Emberfall Aegis") { 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 without alias", catalogInput) 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")) artifact := readProductionJSON[dnd.SpellList](t, filepath.Join(runRoot, "lanes", "spells.json"))
if len(artifact.SpellCasts) != 1 || artifact.SpellCasts[0].Spell != "Aegis of Emberfall" { 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) t.Fatalf("rejection = %#v, want exhausted unknown-spell rejection", rejection)
} }
if len(output.Warnings) != 0 { 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 return
} }

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package config
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -349,6 +350,12 @@ func ParseFileConfigYAML(data []byte) (FileConfig, error) {
if err := decoder.Decode(&fileCfg); err != nil { if err := decoder.Decode(&fileCfg); err != nil {
return FileConfig{}, fmt.Errorf("decode yaml: %w", err) 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 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) { func TestFilePipelineLLMProfileIsPresenceAwareAndDetached(t *testing.T) {
const pipelineYAML = `version: 4 const pipelineYAML = `version: 4
pipelines: pipelines:

View File

@@ -10,6 +10,38 @@ import (
"strings" "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) { func SafePath(root, name string) (string, error) {
root = strings.TrimSpace(root) root = strings.TrimSpace(root)
if 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) { func TestWriteBytesIsAtomicAndUsesRequestedModes(t *testing.T) {
root := t.TempDir() root := t.TempDir()
if err := WriteBytes(root, "nested/value", []byte("value"), 0o700, 0o600); err != nil { 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 { func lanePayloadPath(stage string, stepID string, laneID string, file string) string {
if strings.TrimSpace(stepID) == "" { 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) return path.Join(stage, fileio.EncodePathComponent(stepID), fileio.EncodePathComponent(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
} }
func contentDigest(content []byte) string { func contentDigest(content []byte) string {

View File

@@ -5,6 +5,8 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/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) { func TestCheckpointSchemaCompatibilityIdentifiers(t *testing.T) {
if WorkspaceSchemaVersion != "notarius.workspace.v3" || WorkspaceSchemaVersionV2 != "notarius.workspace.v2" || WorkspaceSchemaVersionV1 != "notarius.workspace.v1" { if WorkspaceSchemaVersion != "notarius.workspace.v3" || WorkspaceSchemaVersionV2 != "notarius.workspace.v2" || WorkspaceSchemaVersionV1 != "notarius.workspace.v1" {
t.Fatal("checkpoint schema identifiers are incorrect") t.Fatal("checkpoint schema identifiers are incorrect")

View File

@@ -102,11 +102,11 @@ func Build(request BuildRequest) (ChunkMap, error) {
Annotations: source.CloneChunkAnnotations(chunk.Annotations), Annotations: source.CloneChunkAnnotations(chunk.Annotations),
} }
} }
canonical, err := canonicalize(value) canonical, err := canonicalizeOwned(value)
if err != nil { if err != nil {
return ChunkMap{}, fmt.Errorf("validate chunk map: %w", err) 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. // 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 { if _, err := c.schemaBytes(); err != nil {
return nil, err return nil, err
} }
canonical, err := canonicalize(clone(value)) canonical, err := canonicalize(value)
if err != nil { if err != nil {
return nil, fmt.Errorf("encode source chunk map: %w", err) 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 { if err := decoder.Decode(&trailing); err != io.EOF {
return ChunkMap{}, fmt.Errorf("decode source chunk map: multiple JSON values") return ChunkMap{}, fmt.Errorf("decode source chunk map: multiple JSON values")
} }
canonical, err := canonicalize(value) canonical, err := canonicalizeOwned(value)
if err != nil { if err != nil {
return ChunkMap{}, fmt.Errorf("decode source chunk map: %w", err) return ChunkMap{}, fmt.Errorf("decode source chunk map: %w", err)
} }
return clone(canonical), nil return canonical, nil
} }
func (c *Codec) schemaBytes() ([]byte, error) { func (c *Codec) schemaBytes() ([]byte, error) {
@@ -241,6 +241,10 @@ func hasRequiredFields(required []string) bool {
} }
func canonicalize(value ChunkMap) (ChunkMap, error) { 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 { if err := requireIdentity("source_id", value.SourceID); err != nil {
return ChunkMap{}, err return ChunkMap{}, err
} }

View File

@@ -56,6 +56,15 @@ func TestCodecRoundTripsValidFixture(t *testing.T) {
if !bytes.Equal(encoded, bytes.TrimSpace(fixture)) { if !bytes.Equal(encoded, bytes.TrimSpace(fixture)) {
t.Fatalf("fixture does not use canonical encoding\nwant: %s\n got: %s", fixture, encoded) t.Fatalf("fixture does not use canonical encoding\nwant: %s\n got: %s", fixture, encoded)
} }
value.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) { func TestBuildCanonicalizesAnnotationFormatting(t *testing.T) {

View File

@@ -50,6 +50,13 @@ type ArtifactCodec[T any] interface {
Decode([]byte) (T, error) 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 // DigestArtifactSchema returns the SHA-256 digest of the exact JSON Schema
// bytes. Schema formatting is therefore part of the registered identity. // bytes. Schema formatting is therefore part of the registered identity.
func DigestArtifactSchema(schema ArtifactSchema) string { func DigestArtifactSchema(schema ArtifactSchema) string {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,15 @@
package llm package llm
import ( import (
"bytes"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"path" "path"
"sort"
"strings" "strings"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/readonlyfs"
"gitea.maximumdirect.net/eric/promptkit" "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) { func flattenAssetSources(sources []AssetSource) (fs.FS, error) {
out := assetMapFS{} out := make(map[string][]byte)
for _, source := range sources { for _, source := range sources {
if err := fs.WalkDir(source.FS, source.Root, func(name string, entry fs.DirEntry, walkErr error) error { if err := fs.WalkDir(source.FS, source.Root, func(name string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
@@ -247,13 +245,13 @@ func flattenAssetSources(sources []AssetSource) (fs.FS, error) {
if err != nil { if err != nil {
return err return err
} }
out[rel] = append([]byte(nil), data...) out[rel] = data
return nil return nil
}); err != nil { }); err != nil {
return nil, fmt.Errorf("walk asset root %s: %w", source.Root, err) 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) { func cleanAssetRoot(root string) (string, error) {
@@ -275,156 +273,3 @@ func cleanAssetPath(name string) (string, error) {
} }
return cleaned, nil 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 { if ctxErr := ctx.Err(); ctxErr != nil {
return contracts.StructuredCompletionResponse{}, ctxErr 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() defer prepared.Discard()
preparedDetails := prepared.Details() preparedDetails := prepared.Details()
@@ -169,7 +169,7 @@ func (c *PromptKitClient) CompleteStructured(ctx context.Context, req contracts.
redactPromptKitError(err), 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 { if result == nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("run PromptKit prompt %q: %w: empty result", promptID, contracts.ErrInvalidStructuredOutput) 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 { if err == nil {
return nil return nil
} }
return redactedProviderError{err: err} return sanitizedProviderDiagnostic{message: bearerTokenPattern.ReplaceAllString(err.Error(), "Bearer "+secretReplacement)}
} }
type redactedProviderError struct { type sanitizedProviderDiagnostic struct {
err error message string
} }
func (e redactedProviderError) Error() string { func (e sanitizedProviderDiagnostic) Error() string {
return bearerTokenPattern.ReplaceAllString(e.err.Error(), "Bearer "+secretReplacement) return e.message
}
func (e redactedProviderError) Unwrap() error {
return e.err
} }

View File

@@ -741,6 +741,11 @@ model: local-model
!strings.Contains(err.Error(), promptkit.BackendLocal) { !strings.Contains(err.Error(), promptkit.BackendLocal) {
t.Fatalf("CompleteStructured() without registration error = %v, want preparation failure with local backend context", err) 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 { if providerCalls.Load() != 1 {
t.Fatalf("provider calls after missing-registration failure = %d, want 1", providerCalls.Load()) 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) { 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 var out map[string]any
resp, err := client.CompleteStructured(context.Background(), contracts.StructuredCompletionRequest{ 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]") { if strings.Contains(err.Error(), "secret-token") || !strings.Contains(err.Error(), "Bearer [REDACTED]") {
t.Fatalf("error = %q, want redacted bearer token", err.Error()) 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 { if resp.Debug != nil {
t.Fatalf("debug material = %#v, want none for provider failure without result", resp.Debug) t.Fatalf("debug material = %#v, want none for provider failure without result", resp.Debug)
} }
@@ -1214,6 +1235,12 @@ type fakePromptKitLLM struct {
maxInFlight int32 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) { func (f *fakePromptKitLLM) Generate(ctx context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
f.mu.Lock() f.mu.Lock()
f.last = req f.last = req

View File

@@ -83,6 +83,9 @@ func (s *Scheduler) Run(ctx context.Context, fn func(context.Context) error) err
return err return err
} }
defer release() defer release()
if err := ctx.Err(); err != nil {
return err
}
return fn(ctx) 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) { func waitForAtomicAtLeast(t *testing.T, value *int32, want int32) {
t.Helper() t.Helper()
deadline := time.Now().Add(time.Second) deadline := time.Now().Add(time.Second)

View File

@@ -69,6 +69,7 @@ type artifactCodecEntry struct {
encodeCandidate func(any) ([]byte, error) encodeCandidate func(any) ([]byte, error)
metadata func(any) (map[string]any, error) metadata func(any) (map[string]any, error)
decode func([]byte) (any, error) decode func([]byte) (any, error)
decodeCandidate func([]byte) (any, error)
} }
func NewArtifactCodecRegistry() *ArtifactCodecRegistry { func NewArtifactCodecRegistry() *ArtifactCodecRegistry {
@@ -77,7 +78,7 @@ func NewArtifactCodecRegistry() *ArtifactCodecRegistry {
// RegisterArtifactCodec registers one codec for T. The concrete type is kept // RegisterArtifactCodec registers one codec for T. The concrete type is kept
// private and checked at every erased encode boundary. // 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 { if registry == nil {
return fmt.Errorf("artifact codec registry must not be 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 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) { entry.encodeCandidate = func(value any) ([]byte, error) {
typed, err := exactTypedValue[T]("encode candidate artifact", value) typed, err := exactTypedValue[T]("encode candidate artifact", value)

View File

@@ -14,7 +14,13 @@ import (
) )
type codecNotes struct { 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 { type codecScore struct {
@@ -24,12 +30,13 @@ type codecScore struct {
type codecNotesAlias codecNotes type codecNotesAlias codecNotes
type testArtifactCodec[T any] struct { type testArtifactCodec[T any] struct {
kind contracts.ArtifactKind kind contracts.ArtifactKind
schema contracts.ArtifactSchema schema contracts.ArtifactSchema
mediaType string mediaType string
encodeFunc func(T) ([]byte, error) encodeFunc func(T) ([]byte, error)
candidateFunc func(T) ([]byte, error) candidateFunc func(T) ([]byte, error)
decodeFunc func([]byte) (T, error) decodeFunc func([]byte) (T, error)
candidateDecodeFunc func([]byte) (T, error)
} }
func (c testArtifactCodec[T]) Kind() contracts.ArtifactKind { return c.kind } 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]) 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]) 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.ArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{}
var _ contracts.CandidateArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{}
func TestArtifactCodecRegistryStoresHeterogeneousExactTypes(t *testing.T) { func TestArtifactCodecRegistryStoresHeterogeneousExactTypes(t *testing.T) {
registry := NewArtifactCodecRegistry() 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) { func TestArtifactCodecRegistryStoresValidatedSchemaMetadata(t *testing.T) {
registry := NewArtifactCodecRegistry() registry := NewArtifactCodecRegistry()
codec := notesCodec() codec := notesCodec()
@@ -164,7 +216,7 @@ func TestArtifactCodecRegistryStoresValidatedSchemaMetadata(t *testing.T) {
spec.Schema.JSONSchema[0] = '[' spec.Schema.JSONSchema[0] = '['
again, _ := registry.Spec("test/notes") 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) 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) { func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
shared := []byte(`{"items":["one"]}`) shared := []byte(`{"items":["one"]}`)
codec := notesCodec() codec := notesCodec()
@@ -275,6 +349,10 @@ func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
content[0] = '[' content[0] = '['
return codecNotes{Items: []string{"one"}}, nil return codecNotes{Items: []string{"one"}}, nil
} }
codec.candidateDecodeFunc = func(content []byte) (codecNotes, error) {
content[0] = '['
return codecNotes{Items: []string{"candidate"}}, nil
}
registry := NewArtifactCodecRegistry() registry := NewArtifactCodecRegistry()
if err := RegisterArtifactCodec(registry, codec); err != nil { if err := RegisterArtifactCodec(registry, codec); err != nil {
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err) t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
@@ -295,6 +373,18 @@ func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
if !bytes.Equal(artifact.Content, before) { if !bytes.Equal(artifact.Content, before) {
t.Fatalf("serialized content changed during decode: %q", artifact.Content) 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] { func notesCodec() testArtifactCodec[codecNotes] {
@@ -304,7 +394,7 @@ func notesCodec() testArtifactCodec[codecNotes] {
ID: "notes.v1", ID: "notes.v1",
Name: "notes", Name: "notes",
Version: "v1", 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", mediaType: "application/json",
encodeFunc: func(value codecNotes) ([]byte, error) { encodeFunc: func(value codecNotes) ([]byte, error) {

View File

@@ -17,6 +17,7 @@ import (
"unicode/utf8" "unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "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/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "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) Enabled() bool { return false }
func (noopDebugRecorder) WriteJSON(string, any) error { return nil } func (noopDebugRecorder) WriteJSON(string, any) error { return nil }
func (noopDebugRecorder) WriteBytes(string, []byte) 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 { type debugTimedEnvelope struct {
Stage string `json:"stage,omitempty"` Stage string `json:"stage,omitempty"`
@@ -242,7 +217,7 @@ func (client *debugLLMClient) CompleteStructured(ctx context.Context, req contra
} }
scopePrefix := cleanDebugPath(req.StageName) scopePrefix := cleanDebugPath(req.StageName)
if scopePrefix == "_" { if req.StageName == "" {
scopePrefix = "llm" scopePrefix = "llm"
} }
if scope := debugLLMScopeFromContext(ctx); scope != nil { if scope := debugLLMScopeFromContext(ctx); scope != nil {
@@ -310,7 +285,6 @@ func withDebugLLMScope(ctx context.Context, prefix string) (context.Context, *de
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
prefix = cleanDebugPath(prefix)
scope := &debugLLMScope{ scope := &debugLLMScope{
prefix: prefix, prefix: prefix,
parent: debugLLMScopeFromContext(ctx), parent: debugLLMScopeFromContext(ctx),
@@ -322,7 +296,6 @@ func withIsolatedDebugLLMScope(ctx context.Context, prefix string) (context.Cont
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
prefix = cleanDebugPath(prefix)
scope := &debugLLMScope{prefix: prefix} scope := &debugLLMScope{prefix: prefix}
return context.WithValue(ctx, debugLLMScopeContextKey{}, scope), scope return context.WithValue(ctx, debugLLMScopeContextKey{}, scope), scope
} }
@@ -362,15 +335,12 @@ func (scope *debugLLMScope) references() []debugLLMCallReference {
} }
func cleanDebugPath(value string) string { func cleanDebugPath(value string) string {
parts := strings.Split(path.Clean(strings.TrimSpace(value)), "/") parts := strings.Split(value, "/")
out := make([]string, 0, len(parts)) out := make([]string, 0, len(parts))
for _, part := range parts { for _, part := range parts {
out = append(out, debugPathComponent(part)) out = append(out, fileio.EncodePathComponent(part))
} }
if len(out) == 0 { return strings.Join(out, "/")
return "_"
}
return path.Join(out...)
} }
func debugFirstNonEmptyString(values ...string) string { func debugFirstNonEmptyString(values ...string) string {

View File

@@ -1,13 +1,52 @@
package pipeline package pipeline
import ( import (
"context"
"encoding/json" "encoding/json"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
) )
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) { func TestDebugSourceDocumentPreservesUnitReferences(t *testing.T) {
doc := validSourceDocument() doc := validSourceDocument()
envelope := debugSourceDocumentEnvelope(doc) envelope := debugSourceDocumentEnvelope(doc)

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package pipeline
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"reflect" "reflect"
"strings" "strings"
"testing" "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) { func TestGeneratedReferenceAcceptsTypedEmptyCollection(t *testing.T) {
input, step, producerOutput := handoffFixture(t, codecNotes{}) input, step, producerOutput := handoffFixture(t, codecNotes{})
sets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{producerOutput}) 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 { {name: "multiple", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
return []contracts.SerializedOutput{output, output} return []contracts.SerializedOutput{output, output}
}, want: "exactly one is required"}, }, 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 { {name: "kind mismatch", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
return []contracts.SerializedOutput{output} return []contracts.SerializedOutput{output}
}, mutate: func(target *ResolvedReferenceTarget, _ *contracts.SerializedOutput) { }, 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) { func TestGeneratedReferenceFingerprintChangesWithCanonicalContent(t *testing.T) {
input, step, first := handoffFixture(t, codecNotes{Items: []string{"first"}}) input, step, first := handoffFixture(t, codecNotes{Items: []string{"first"}})
firstSets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{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) { func TestRunnerHandsOffAcceptedNormalizedOutputBeforeConsumerLanes(t *testing.T) {
input, _, _ := handoffFixture(t, codecNotes{Items: []string{"first"}}) input, _, _ := handoffFixture(t, codecNotes{Items: []string{"first"}})
prepared := input.Prepared prepared := input.Prepared

View File

@@ -305,6 +305,7 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
if name != "extract" { if name != "extract" {
return return
} }
request.Options["nested"].(map[string]any)["value"] = "mutated by extractor builder"
slot := request.References.Slots["extract"] slot := request.References.Slots["extract"]
slot.Items[0].Content = []byte("mutated by extractor builder") slot.Items[0].Content = []byte("mutated by extractor builder")
request.References.Slots["extract"] = slot 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].ExtractReferences.ReferenceSet = constructionReferenceSet("extract", "extract reference")
resolved.Steps[0].ArtifactLanes[0].MergeReferences.ReferenceSet = constructionReferenceSet("merge", "merge 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].NormalizeReferences.ReferenceSet = constructionReferenceSet("normalize", "normalize reference")
resolved.Steps[0].ArtifactLanes[0].Extract.Options = constructionBuildRequest().Options
prepared, err := Prepare(resolved, registries, ModuleDependencies{}) prepared, err := Prepare(resolved, registries, ModuleDependencies{})
if err != nil { 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" { if got := constructionReferenceContent(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" {
t.Fatalf("resolved extract references = %q, want original content", got) 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{ _, 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), 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) { func TestPrepareFailuresOccurBeforeInputParse(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

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

View File

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

View File

@@ -3,7 +3,10 @@ package pipeline
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"io"
"math"
"mime" "mime"
"net/url" "net/url"
"os" "os"
@@ -124,10 +127,30 @@ func materializeReferenceTarget(
if err != nil { if err != nil {
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q: %w", referenceTargetContext(pipelineID, target), slotName, binding.Source, err) 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 { if err != nil {
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q read %q: %w", referenceTargetContext(pipelineID, target), slotName, path, err) 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) { 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) 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) { 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) 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 { if len(content) == 0 {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: referenceWarningScope(pipelineID, target, slotName), Scope: referenceWarningScope(pipelineID, target, slotName),
@@ -163,6 +183,32 @@ func materializeReferenceTarget(
return set, warnings, nil 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) { func referenceTargetSpec(target ResolvedReferenceTarget, artifactKind contracts.ArtifactKind, catalog ModuleCatalog) (ModuleSpec, error) {
switch target.Stage { switch target.Stage {
case StageChunk: case StageChunk:

View File

@@ -2,6 +2,9 @@ package pipeline
import ( import (
"encoding/json" "encoding/json"
"errors"
"io"
"math"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
@@ -12,6 +15,61 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "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) { func TestMaterializeReferencesResolvesPathsAndDigestsContent(t *testing.T) {
configDir := t.TempDir() configDir := t.TempDir()
workingDir := t.TempDir() workingDir := t.TempDir()

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