Compare commits
69 Commits
516af12916
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| bef8ca263b | |||
| f6d037b613 | |||
| 449b506804 | |||
| 071a78ae22 | |||
| ad1cba41c2 | |||
| 67338798aa | |||
| e95e2f2220 | |||
| 628b8d1800 | |||
| d24d4609b6 | |||
| c7f79fb38e | |||
| 8c071800cf | |||
| 569e12c6f4 | |||
| 5b6eb591b2 | |||
| b630384aa0 | |||
| 297d58f090 | |||
| ee71dc4937 | |||
| 65e5d65d14 | |||
| b40b40aaf3 | |||
| f120be1cb4 | |||
| 17673d74ea | |||
| ef19a03cbf | |||
| 0546f6eb4f | |||
| d28d1062e0 | |||
| b3ebfcef37 | |||
| b70d9f77e3 | |||
| 2a75f40871 | |||
| a705ba74a1 | |||
| 8d9c9e7c87 | |||
| 0b5cc4f251 | |||
| 82ffe85f2d | |||
| b3644abc0e | |||
| d653bf1b90 | |||
| 3e66127b94 | |||
| 5d086c13ca | |||
| d36d4e7689 | |||
| 1456aa51cc | |||
| ffc179c822 | |||
| 8e669a1f14 | |||
| 14bfae216d | |||
| 5a58d87995 | |||
| 557809f364 | |||
| ee600975f0 | |||
| 0d8017e23f | |||
| 37b18edf3d | |||
| cda7a61b47 | |||
| 2ad9283148 | |||
| 41a8a80dda | |||
| 90c7fa6381 | |||
| e3839f8620 | |||
| 0fc2f9ee01 | |||
| 5d6305f21a | |||
| 551e4daea2 | |||
| 3589d33468 | |||
| a22c1a7f59 | |||
| ad85d71b0f | |||
| f3506240c2 | |||
| 70c199aa31 | |||
| e2b82746ab | |||
| 4235507f7b | |||
| b346670cc7 | |||
| 7868c26be7 | |||
| 92e89076a2 | |||
| d9b87347b8 | |||
| 20397ef710 | |||
| fc449863f2 | |||
| 51d62de1f3 | |||
| fc76805075 | |||
| 8e680cf96e | |||
| ece1bca460 |
@@ -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"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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" }}
|
||||||
|
|||||||
@@ -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"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" }}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
Location candidates:
|
|
||||||
{{ input "candidates" }}
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
NPC candidates for identity comparison:
|
|
||||||
|
|
||||||
{{ input "candidates" }}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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" }}
|
||||||
|
|||||||
@@ -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" }}
|
||||||
|
|||||||
@@ -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" }}
|
|
||||||
@@ -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" }}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
Item candidates:
|
Candidate material:
|
||||||
|
|
||||||
{{ input "candidates" }}
|
{{ input "candidates" }}
|
||||||
@@ -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.
|
||||||
27
assets/generic/normalize/deduplication/prompts/prompt.yaml
Normal file
27
assets/generic/normalize/deduplication/prompts/prompt.yaml
Normal 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
|
||||||
@@ -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.
|
||||||
2
assets/generic/normalize/deduplication/prompts/system.md
Normal file
2
assets/generic/normalize/deduplication/prompts/system.md
Normal 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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Transcript evidence windows:
|
||||||
|
|
||||||
|
{{ input "transcript" }}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
registry’s source references are never occurrence evidence. Unknown IDs and
|
into the unchanged exact durable ID/name pair. The registry’s 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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ including a collision with the embedded catalog. Matching uses the catalog’s
|
|||||||
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).
|
||||||
|
|||||||
@@ -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,35 +28,13 @@ 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",
|
|
||||||
"window_units": 1,
|
|
||||||
"selected_lanes": ["npc_registry", "spells"],
|
|
||||||
"contexts": [
|
|
||||||
{
|
|
||||||
"context_ref": {
|
|
||||||
"source_id": "session-alpha",
|
|
||||||
"start_unit_id": 10,
|
|
||||||
"end_unit_id": 20
|
|
||||||
},
|
|
||||||
"evidence_refs": [
|
|
||||||
{
|
|
||||||
"lane_id": "spells",
|
|
||||||
"source_ref": {
|
|
||||||
"source_id": "session-alpha",
|
|
||||||
"start_unit_id": 10,
|
|
||||||
"end_unit_id": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"units": [
|
|
||||||
{
|
{
|
||||||
"id": 10,
|
"id": 10,
|
||||||
"kind": "transcript_segment",
|
"kind": "transcript_segment",
|
||||||
@@ -75,42 +55,53 @@ evidence publishes `contexts: []`.
|
|||||||
"end_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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
2474
docs/roadmap/archive/audit.md
Normal file
2474
docs/roadmap/archive/audit.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
81
internal/cli/assembled_enemy_event_codec_contract_test.go
Normal file
81
internal/cli/assembled_enemy_event_codec_contract_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, ®istry); err != nil {
|
if err := json.Unmarshal(request.Inputs["item_registry"].Content, ®istry); 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, ®istry); err != nil {
|
if err := json.Unmarshal(request.Inputs["npc_registry"].Content, ®istry); 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, ®istry); err != nil {
|
if err := json.Unmarshal(request.Inputs["location_registry"].Content, ®istry); 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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["source_id", "source_digest", "window_units", "selected_lanes", "contexts"],
|
|
||||||
"properties": {
|
|
||||||
"source_id": {"type": "string", "minLength": 1},
|
|
||||||
"source_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
|
|
||||||
"window_units": {"type": "integer", "minimum": 0},
|
|
||||||
"selected_lanes": {
|
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"items": {"$ref": "#/$defs/unit"},
|
||||||
"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"}}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
laneID string
|
|
||||||
ref source.SourceRef
|
|
||||||
startPos int
|
|
||||||
endPos int
|
|
||||||
}
|
|
||||||
|
|
||||||
type expandedRange struct {
|
type expandedRange struct {
|
||||||
startPos int
|
startPos int
|
||||||
endPos int
|
endPos int
|
||||||
contributions []contribution
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build validates accepted direct references, expands them by source-document
|
// Build validates projected source references, expands them by source-document
|
||||||
// position, and returns their deterministic context union.
|
// position, and returns their ordered union as an owned source-unit excerpt.
|
||||||
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 {
|
|
||||||
laneID := strings.TrimSpace(laneEvidence.LaneID)
|
|
||||||
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 {
|
|
||||||
if err := index.ValidateRef(ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
return Document{}, fmt.Errorf("lane %q source reference[%d]: %w", laneID, refIndex, err)
|
return nil, fmt.Errorf("source reference[%d]: %w", refIndex, err)
|
||||||
}
|
}
|
||||||
key := evidenceKey{laneID: laneID, ref: ref}
|
|
||||||
if _, exists := seen[key]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
startPos, _ := index.Position(ref.StartUnitID)
|
startPos, _ := index.Position(ref.StartUnitID)
|
||||||
endPos, _ := index.Position(ref.EndUnitID)
|
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)})
|
ranges = append(ranges, expandedRange{
|
||||||
}
|
startPos: expandStart(startPos, request.WindowUnits),
|
||||||
|
endPos: expandEnd(endPos, len(request.Source.Units), request.WindowUnits),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(contributions, func(i, j int) bool { return lessContribution(contributions[i], contributions[j]) })
|
merged := mergeRanges(ranges)
|
||||||
document := Document{
|
unitCount := 0
|
||||||
SourceID: request.Source.ID,
|
for _, value := range merged {
|
||||||
SourceDigest: digest,
|
unitCount += value.endPos - value.startPos + 1
|
||||||
WindowUnits: request.WindowUnits,
|
|
||||||
SelectedLanes: lanes,
|
|
||||||
Contexts: make([]Context, 0),
|
|
||||||
}
|
}
|
||||||
for _, rangeValue := range mergeRanges(contributions) {
|
document := make(Document, 0, unitCount)
|
||||||
context, err := buildContext(request.Source, rangeValue)
|
for _, value := range merged {
|
||||||
|
for position := value.startPos; position <= value.endPos; position++ {
|
||||||
|
unit, err := cloneSourceUnit(request.Source.Units[position])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Document{}, err
|
return nil, fmt.Errorf("clone source unit at position %d: %w", position, err)
|
||||||
}
|
}
|
||||||
document.Contexts = append(document.Contexts, context)
|
document = append(document, unit)
|
||||||
}
|
}
|
||||||
canonical, err := canonicalize(document)
|
|
||||||
if err != nil {
|
|
||||||
return Document{}, fmt.Errorf("validate evidence context: %w", err)
|
|
||||||
}
|
}
|
||||||
return clone(canonical)
|
return document, nil
|
||||||
}
|
|
||||||
|
|
||||||
type evidenceKey struct {
|
|
||||||
laneID string
|
|
||||||
ref source.SourceRef
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeSelectedLanes(values []string) ([]string, error) {
|
|
||||||
if len(values) == 0 {
|
|
||||||
return nil, fmt.Errorf("selected_lanes must not be empty")
|
|
||||||
}
|
|
||||||
seen := make(map[string]struct{}, len(values))
|
|
||||||
lanes := make([]string, 0, len(values))
|
|
||||||
for index, raw := range values {
|
|
||||||
laneID := strings.TrimSpace(raw)
|
|
||||||
if laneID == "" {
|
|
||||||
return nil, fmt.Errorf("selected_lanes[%d] must not be empty", index)
|
|
||||||
}
|
|
||||||
if _, exists := seen[laneID]; exists {
|
|
||||||
return nil, fmt.Errorf("selected_lanes lane %q is duplicated", laneID)
|
|
||||||
}
|
|
||||||
seen[laneID] = struct{}{}
|
|
||||||
lanes = append(lanes, laneID)
|
|
||||||
}
|
|
||||||
sort.Strings(lanes)
|
|
||||||
return lanes, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func expandStart(position, window int) int {
|
func expandStart(position, window int) int {
|
||||||
@@ -132,65 +76,25 @@ func expandEnd(position, length, window int) int {
|
|||||||
return position + window
|
return position + window
|
||||||
}
|
}
|
||||||
|
|
||||||
func lessContribution(left, right contribution) bool {
|
func mergeRanges(values []expandedRange) []expandedRange {
|
||||||
if left.startPos != right.startPos {
|
|
||||||
return left.startPos < right.startPos
|
|
||||||
}
|
|
||||||
if left.endPos != right.endPos {
|
|
||||||
return left.endPos < right.endPos
|
|
||||||
}
|
|
||||||
return lessEvidenceRef(EvidenceRef{LaneID: left.laneID, SourceRef: left.ref}, EvidenceRef{LaneID: right.laneID, SourceRef: right.ref})
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeRanges(values []contribution) []expandedRange {
|
|
||||||
if len(values) == 0 {
|
if len(values) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ranges := make([]expandedRange, 0, len(values))
|
sort.Slice(values, func(i, j int) bool {
|
||||||
|
if values[i].startPos != values[j].startPos {
|
||||||
|
return values[i].startPos < values[j].startPos
|
||||||
|
}
|
||||||
|
return values[i].endPos < values[j].endPos
|
||||||
|
})
|
||||||
|
merged := make([]expandedRange, 0, len(values))
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if len(ranges) == 0 || value.startPos > ranges[len(ranges)-1].endPos+1 {
|
if len(merged) == 0 || value.startPos > merged[len(merged)-1].endPos+1 {
|
||||||
ranges = append(ranges, expandedRange{startPos: value.startPos, endPos: value.endPos, contributions: []contribution{value}})
|
merged = append(merged, value)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
current := &ranges[len(ranges)-1]
|
if value.endPos > merged[len(merged)-1].endPos {
|
||||||
if value.endPos > current.endPos {
|
merged[len(merged)-1].endPos = value.endPos
|
||||||
current.endPos = value.endPos
|
|
||||||
}
|
}
|
||||||
current.contributions = append(current.contributions, value)
|
|
||||||
}
|
}
|
||||||
return ranges
|
return merged
|
||||||
}
|
|
||||||
|
|
||||||
func buildContext(document *source.SourceDocument, value expandedRange) (Context, error) {
|
|
||||||
evidenceRefs := make([]EvidenceRef, 0, len(value.contributions))
|
|
||||||
for _, contribution := range value.contributions {
|
|
||||||
evidenceRefs = append(evidenceRefs, EvidenceRef{LaneID: contribution.laneID, SourceRef: contribution.ref})
|
|
||||||
}
|
|
||||||
sort.Slice(evidenceRefs, func(i, j int) bool { return lessEvidenceRef(evidenceRefs[i], evidenceRefs[j]) })
|
|
||||||
units := make([]source.SourceUnit, 0, value.endPos-value.startPos+1)
|
|
||||||
for position := value.startPos; position <= value.endPos; position++ {
|
|
||||||
unit, err := cloneSourceUnit(document.Units[position])
|
|
||||||
if err != nil {
|
|
||||||
return Context{}, fmt.Errorf("clone source unit at position %d: %w", position, err)
|
|
||||||
}
|
|
||||||
units = append(units, unit)
|
|
||||||
}
|
|
||||||
return Context{
|
|
||||||
ContextRef: source.SourceRef{SourceID: document.ID, StartUnitID: units[0].ID, EndUnitID: units[len(units)-1].ID},
|
|
||||||
EvidenceRefs: evidenceRefs,
|
|
||||||
Units: units,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func lessEvidenceRef(left, right EvidenceRef) bool {
|
|
||||||
if left.LaneID != right.LaneID {
|
|
||||||
return left.LaneID < right.LaneID
|
|
||||||
}
|
|
||||||
if left.SourceRef.SourceID != right.SourceRef.SourceID {
|
|
||||||
return left.SourceRef.SourceID < right.SourceRef.SourceID
|
|
||||||
}
|
|
||||||
if left.SourceRef.StartUnitID != right.SourceRef.StartUnitID {
|
|
||||||
return left.SourceRef.StartUnitID < right.SourceRef.StartUnitID
|
|
||||||
}
|
|
||||||
return left.SourceRef.EndUnitID < right.SourceRef.EndUnitID
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -118,14 +115,13 @@ func loadAndCompileSchema() {
|
|||||||
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
|
|
||||||
|
func canonicalizeOwned(value Document) (Document, error) {
|
||||||
|
if value == nil {
|
||||||
|
return nil, fmt.Errorf("document must be a JSON array")
|
||||||
}
|
}
|
||||||
if !digestPattern.MatchString(value.SourceDigest) {
|
seenUnitIDs := make(map[int]struct{}, len(value))
|
||||||
return Document{}, fmt.Errorf("source_digest must be a sha256 digest")
|
sourceID := ""
|
||||||
|
for unitIndex := range value {
|
||||||
|
unit := value[unitIndex]
|
||||||
|
if unit.ID <= 0 || strings.TrimSpace(unit.Kind) == "" || strings.TrimSpace(unit.Text) == "" {
|
||||||
|
return nil, fmt.Errorf("units[%d] has invalid required fields", unitIndex)
|
||||||
}
|
}
|
||||||
if value.WindowUnits < 0 {
|
if err := validateUnitRef(unit, unitIndex); err != nil {
|
||||||
return Document{}, fmt.Errorf("window_units must not be negative")
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateSelectedLanes(value.SelectedLanes); err != nil {
|
if sourceID == "" {
|
||||||
return Document{}, err
|
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 value.Contexts == nil {
|
if _, exists := seenUnitIDs[unit.ID]; exists {
|
||||||
value.Contexts = make([]Context, 0)
|
return nil, fmt.Errorf("units contains duplicate unit id %d", unit.ID)
|
||||||
}
|
}
|
||||||
selected := make(map[string]struct{}, len(value.SelectedLanes))
|
seenUnitIDs[unit.ID] = struct{}{}
|
||||||
for _, laneID := range value.SelectedLanes {
|
|
||||||
selected[laneID] = struct{}{}
|
|
||||||
}
|
|
||||||
seenUnits := make(map[int]struct{})
|
|
||||||
for contextIndex := range value.Contexts {
|
|
||||||
context, err := canonicalizeContext(value.SourceID, selected, seenUnits, value.Contexts[contextIndex], contextIndex)
|
|
||||||
if err != nil {
|
|
||||||
return Document{}, err
|
|
||||||
}
|
|
||||||
value.Contexts[contextIndex] = context
|
|
||||||
}
|
}
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateSelectedLanes(lanes []string) error {
|
func validateUnitRef(unit source.SourceUnit, unitIndex int) error {
|
||||||
if len(lanes) == 0 {
|
prefix := fmt.Sprintf("units[%d].ref", unitIndex)
|
||||||
return fmt.Errorf("selected_lanes must not be empty")
|
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)
|
||||||
for index, laneID := range lanes {
|
|
||||||
if err := requireIdentity(fmt.Sprintf("selected_lanes[%d]", index), laneID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if index > 0 && lanes[index-1] >= laneID {
|
|
||||||
return fmt.Errorf("selected_lanes must be unique and in lexical order")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func canonicalizeContext(sourceID string, selected map[string]struct{}, seenUnits map[int]struct{}, value Context, contextIndex int) (Context, error) {
|
|
||||||
prefix := fmt.Sprintf("contexts[%d]", contextIndex)
|
|
||||||
if len(value.EvidenceRefs) == 0 {
|
|
||||||
return Context{}, fmt.Errorf("%s.evidence_refs must not be empty", prefix)
|
|
||||||
}
|
|
||||||
if len(value.Units) == 0 {
|
|
||||||
return Context{}, fmt.Errorf("%s.units must not be empty", prefix)
|
|
||||||
}
|
|
||||||
if err := validateRefIdentity(sourceID, value.ContextRef, prefix+".context_ref"); err != nil {
|
|
||||||
return Context{}, err
|
|
||||||
}
|
|
||||||
positions := make(map[int]int, len(value.Units))
|
|
||||||
for unitIndex := range value.Units {
|
|
||||||
unit, err := cloneSourceUnit(value.Units[unitIndex])
|
|
||||||
if err != nil {
|
|
||||||
return Context{}, fmt.Errorf("%s.units[%d]: %w", prefix, unitIndex, err)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
if err := validateRefIdentity(sourceID, unit.Ref, fmt.Sprintf("%s.units[%d].ref", prefix, unitIndex)); err != nil {
|
|
||||||
return Context{}, err
|
|
||||||
}
|
}
|
||||||
if unit.Ref.StartUnitID != unit.ID || unit.Ref.EndUnitID != unit.ID {
|
if unit.Ref.StartUnitID != unit.ID || unit.Ref.EndUnitID != unit.ID {
|
||||||
return Context{}, fmt.Errorf("%s.units[%d].ref must identify unit id %d", prefix, unitIndex, unit.ID)
|
return fmt.Errorf("%s must identify unit id %d", prefix, unit.ID)
|
||||||
}
|
|
||||||
if _, exists := positions[unit.ID]; exists {
|
|
||||||
return Context{}, fmt.Errorf("%s.units contains duplicate unit id %d", prefix, unit.ID)
|
|
||||||
}
|
|
||||||
if _, exists := seenUnits[unit.ID]; exists {
|
|
||||||
return Context{}, fmt.Errorf("contexts contain duplicate unit id %d", unit.ID)
|
|
||||||
}
|
|
||||||
positions[unit.ID] = unitIndex
|
|
||||||
seenUnits[unit.ID] = struct{}{}
|
|
||||||
value.Units[unitIndex] = unit
|
|
||||||
}
|
|
||||||
if value.ContextRef.StartUnitID != value.Units[0].ID || value.ContextRef.EndUnitID != value.Units[len(value.Units)-1].ID {
|
|
||||||
return Context{}, fmt.Errorf("%s.context_ref must identify the first and last units", prefix)
|
|
||||||
}
|
|
||||||
for evidenceIndex := range value.EvidenceRefs {
|
|
||||||
evidence := value.EvidenceRefs[evidenceIndex]
|
|
||||||
if _, ok := selected[evidence.LaneID]; !ok {
|
|
||||||
return Context{}, fmt.Errorf("%s.evidence_refs[%d].lane_id is not selected", prefix, evidenceIndex)
|
|
||||||
}
|
|
||||||
if err := requireIdentity(fmt.Sprintf("%s.evidence_refs[%d].lane_id", prefix, evidenceIndex), evidence.LaneID); err != nil {
|
|
||||||
return Context{}, err
|
|
||||||
}
|
|
||||||
if err := validateRefIdentity(sourceID, evidence.SourceRef, fmt.Sprintf("%s.evidence_refs[%d].source_ref", prefix, evidenceIndex)); err != nil {
|
|
||||||
return Context{}, err
|
|
||||||
}
|
|
||||||
start, startOK := positions[evidence.SourceRef.StartUnitID]
|
|
||||||
end, endOK := positions[evidence.SourceRef.EndUnitID]
|
|
||||||
if !startOK || !endOK || start > end {
|
|
||||||
return Context{}, fmt.Errorf("%s.evidence_refs[%d].source_ref is outside context units", prefix, evidenceIndex)
|
|
||||||
}
|
|
||||||
if evidenceIndex > 0 && !lessEvidenceRef(value.EvidenceRefs[evidenceIndex-1], evidence) {
|
|
||||||
return Context{}, fmt.Errorf("%s.evidence_refs must be unique and in deterministic order", prefix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateRefIdentity(sourceID string, ref source.SourceRef, field string) error {
|
|
||||||
if ref.SourceID != sourceID {
|
|
||||||
return fmt.Errorf("%s.source_id does not match source_id", field)
|
|
||||||
}
|
|
||||||
if ref.StartUnitID <= 0 || ref.EndUnitID <= 0 {
|
|
||||||
return fmt.Errorf("%s endpoints must be positive", field)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireIdentity(field, value string) error {
|
|
||||||
if strings.TrimSpace(value) == "" || strings.TrimSpace(value) != value {
|
|
||||||
return fmt.Errorf("%s must be a non-empty trimmed string", field)
|
|
||||||
}
|
}
|
||||||
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 {
|
cloned := make(Document, len(value))
|
||||||
contexts := make([]Context, len(value.Contexts))
|
for unitIndex, unit := range value {
|
||||||
for contextIndex, context := range value.Contexts {
|
owned, err := cloneSourceUnit(unit)
|
||||||
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 {
|
if err != nil {
|
||||||
return Document{}, fmt.Errorf("clone contexts[%d].units[%d]: %w", contextIndex, unitIndex, err)
|
return nil, fmt.Errorf("clone units[%d]: %w", unitIndex, err)
|
||||||
}
|
}
|
||||||
contexts[contextIndex].Units[unitIndex] = cloned
|
cloned[unitIndex] = owned
|
||||||
}
|
}
|
||||||
}
|
return cloned, nil
|
||||||
value.Contexts = contexts
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneSourceUnit(unit source.SourceUnit) (source.SourceUnit, error) {
|
func cloneSourceUnit(unit source.SourceUnit) (source.SourceUnit, error) {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}]
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ 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 {
|
||||||
@@ -30,6 +36,7 @@ type testArtifactCodec[T any] struct {
|
|||||||
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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
@@ -51,8 +50,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user