Compare commits
61 Commits
92e89076a2
...
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 |
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "notarius.dnd.entity_reconcile.llm",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["duplicate_groups"],
|
||||
"properties": {
|
||||
"duplicate_groups": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["members", "canonical"],
|
||||
"properties": {
|
||||
"members": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/selector"}
|
||||
},
|
||||
"canonical": {"$ref": "#/$defs/selector"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"selector": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "source_refs"],
|
||||
"properties": {
|
||||
"name": {"type": "string", "minLength": 1},
|
||||
"source_refs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["start_unit_id", "end_unit_id"],
|
||||
"properties": {
|
||||
"start_unit_id": {"type": "integer", "minimum": 1},
|
||||
"end_unit_id": {"type": "integer", "minimum": 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,10 @@
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["start_segment", "end_segment"],
|
||||
"required": ["start_unit_id", "end_unit_id"],
|
||||
"properties": {
|
||||
"start_segment": {"type": "integer"},
|
||||
"end_segment": {"type": "integer"}
|
||||
"start_unit_id": {"type": "integer"},
|
||||
"end_unit_id": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
Use candidate names and cited transcript windows only to determine whether
|
||||
candidates identify the same item type or unique designation. Do not treat
|
||||
nearby evidence, similar objects, or a shared owner as sufficient. Keep
|
||||
currency denominations, materially different item types, and uncertain aliases
|
||||
separate. Do not infer an item property or uniqueness.
|
||||
Determine whether candidates identify the same item type or unique designation
|
||||
using their contextual labels and cited transcript windows. Do not treat nearby
|
||||
evidence, similar objects, or a shared owner as sufficient.
|
||||
|
||||
Keep currency denominations and materially different item types separate. Keep
|
||||
uncertain aliases separate. Do not infer an item property or uniqueness.
|
||||
|
||||
When selecting a canonical display name, choose one supplied candidate name
|
||||
that is the clearest established designation.
|
||||
|
||||
@@ -12,19 +12,19 @@ messages:
|
||||
- role: system
|
||||
content_file: ./sharedassets/common-dnd-system.md
|
||||
- role: user
|
||||
content_file: ./instructions.md
|
||||
content_file: ./sharedassets/protocol.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-entity-reconciliation.md
|
||||
content_file: ./instructions.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./candidates.md
|
||||
content_file: ./sharedassets/candidates.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-transcript-windows.md
|
||||
content_file: ./sharedassets/transcript-windows.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
output:
|
||||
format: json
|
||||
validation_mode: json_schema
|
||||
schema_path: dnd_entity_reconcile_llm.v1.json
|
||||
schema_path: semantic_reconciliation_llm.v1.json
|
||||
repair_attempts: 0
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
Location candidates:
|
||||
{{ input "candidates" }}
|
||||
@@ -1,6 +1,8 @@
|
||||
Use candidate names and their cited transcript windows to determine whether
|
||||
candidates identify the same physical place. Do not treat matching names,
|
||||
nearby evidence, nested places, or generic labels as sufficient. Keep parent
|
||||
and child places, similarly named places, and uncertain aliases separate.
|
||||
Determine whether candidates identify the same physical place using their
|
||||
contextual labels and cited transcript windows. Do not treat matching names,
|
||||
nearby evidence, nested places, or generic labels as sufficient.
|
||||
|
||||
Keep parent and child places separate, as well as similarly named places and
|
||||
uncertain aliases.
|
||||
|
||||
When selecting a canonical display name, prefer the clearest established name.
|
||||
|
||||
@@ -12,19 +12,19 @@ messages:
|
||||
- role: system
|
||||
content_file: ./sharedassets/common-dnd-system.md
|
||||
- role: user
|
||||
content_file: ./instructions.md
|
||||
content_file: ./sharedassets/protocol.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-entity-reconciliation.md
|
||||
content_file: ./instructions.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./candidates.md
|
||||
content_file: ./sharedassets/candidates.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-transcript-windows.md
|
||||
content_file: ./sharedassets/transcript-windows.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
output:
|
||||
format: json
|
||||
validation_mode: json_schema
|
||||
schema_path: dnd_entity_reconcile_llm.v1.json
|
||||
schema_path: semantic_reconciliation_llm.v1.json
|
||||
repair_attempts: 0
|
||||
|
||||
@@ -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
|
||||
candidates refer to the same individual. Preserve distinct individuals even
|
||||
when their names are similar.
|
||||
Determine whether candidates refer to the same individual using their
|
||||
contextual labels and cited transcript windows. Preserve distinct individuals
|
||||
even when their names are similar or their contextual descriptions are
|
||||
identical.
|
||||
|
||||
When selecting a canonical display name, prefer a complete, stable proper name
|
||||
over an abbreviation. Prefer an unadorned proper name over that name plus a
|
||||
|
||||
@@ -12,19 +12,19 @@ messages:
|
||||
- role: system
|
||||
content_file: ./sharedassets/common-dnd-system.md
|
||||
- role: user
|
||||
content_file: ./instructions.md
|
||||
content_file: ./sharedassets/protocol.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-entity-reconciliation.md
|
||||
content_file: ./instructions.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./candidates.md
|
||||
content_file: ./sharedassets/candidates.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-transcript-windows.md
|
||||
content_file: ./sharedassets/transcript-windows.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
output:
|
||||
format: json
|
||||
validation_mode: json_schema
|
||||
schema_path: dnd_entity_reconcile_llm.v1.json
|
||||
schema_path: semantic_reconciliation_llm.v1.json
|
||||
repair_attempts: 0
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
Identify only well-supported duplicate groups among the supplied candidates.
|
||||
|
||||
Return each selected candidate's supplied contextual descriptor exactly: its
|
||||
`name` and complete ordered `source_refs`. A group must contain at least two
|
||||
supplied descriptors, and its `canonical` descriptor must be one of its
|
||||
members. Do not invent names, ranges, records, evidence, or replacement values.
|
||||
Omit any uncertain or unsafe group.
|
||||
@@ -1,7 +1,6 @@
|
||||
Transcript units are the only evidence for extracted events and factual claims.
|
||||
Every reported factual claim must be supported by cited transcript units. Use
|
||||
integer `start_unit_id` and `end_unit_id` values from the transcript. Omit
|
||||
`source_id`; Notarius assigns the current source identity.
|
||||
integer `start_unit_id` and `end_unit_id` values from the transcript.
|
||||
|
||||
When supporting evidence is non-contiguous, use multiple narrow ranges rather
|
||||
than a broad range that bridges unrelated conversation.
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
You process Dungeons & Dragons gameplay transcripts.
|
||||
|
||||
Rely only on the supplied inputs. They may contain transcription errors,
|
||||
repeated lines, incomplete sentences, and misheard proper nouns.
|
||||
As input, you will receive one or more portions of a transcript. The transcript may contain transcription errors, repeated lines, incomplete sentences, and misheard proper nouns.
|
||||
|
||||
Return exactly one JSON object that conforms to the configured response schema,
|
||||
with no explanatory prose.
|
||||
Return exactly one JSON object that conforms to the configured response schema, with no explanatory prose.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
One extraction chunk from a Dungeons & Dragons gameplay transcript is provided
|
||||
below. Report and infer only what is within this chunk. Its unit IDs retain
|
||||
their source-wide meaning.
|
||||
One extraction chunk from a Dungeons & Dragons gameplay transcript is provided below. Report and infer only what is within this chunk. Its unit IDs retain their source-wide meaning.
|
||||
|
||||
{{ input "transcript" }}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
The complete ordered transcript of this Dungeons & Dragons gameplay session is
|
||||
provided below. It may contain multiple scenes.
|
||||
The complete ordered transcript of this Dungeons & Dragons gameplay session is provided below.
|
||||
|
||||
{{ input "transcript" }}
|
||||
|
||||
@@ -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.
|
||||
Return spell names using the catalog's canonical spelling exactly. Aliases and
|
||||
other campaign reference material are not part of this catalog input and must
|
||||
not be copied into the output as spell names.
|
||||
The spell catalog for this extraction is provided below as JSON. Each entry
|
||||
lists a `canonical_name` and its recognized `aliases`. If the transcript uses
|
||||
an alias, select that entry's `canonical_name`. Return spell names using the
|
||||
canonical spelling exactly; never return an alias as a spell name.
|
||||
|
||||
{{ input "spell_catalog" }}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
Item candidates:
|
||||
Candidate material:
|
||||
|
||||
{{ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,9 @@ an opaque implementation detail. A plain name is likewise insufficient where
|
||||
multiple supplied records share that name.
|
||||
|
||||
The LLM boundary must preserve the typed artifact and durable-schema ownership
|
||||
of [ADR-0003](0003-strongly-typed-stage-interfaces.md) and the distinction
|
||||
of [ADR-0003](0003-typed-interfaces-with-two-zone-data-model.md) and the distinction
|
||||
between disambiguating references and source evidence in
|
||||
[ADR-0009](0009-prefer-minimal-evidence-grounded-extraction-artifacts.md).
|
||||
[ADR-0009](0009-minimal-evidence-grounded-extraction-artifacts.md).
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
@@ -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
|
||||
lane that is not active for that invocation simply contributes no evidence.
|
||||
Evidence publication is opt-in because it can persist source text and metadata.
|
||||
Its payload contract is [Published Evidence Context](integrations/evidence-context.md).
|
||||
When enabled, it publishes the selected source-unit excerpt defined by the
|
||||
[Published Evidence Context contract](integrations/evidence-context.md).
|
||||
|
||||
## References And Ordered Handoffs
|
||||
|
||||
|
||||
@@ -55,10 +55,10 @@ contract. The JSON bundle contract links to the available lane contracts.
|
||||
If `index.json` has an `evidence_context` descriptor, treat it as a
|
||||
pipeline-wide artifact rather than a lane entry. Verify its six descriptor
|
||||
fields before decoding the linked file according to the [Published Evidence
|
||||
Context contract](../integrations/evidence-context.md). Use each
|
||||
`evidence_refs` entry as the citation to source material. Its surrounding
|
||||
context range and included units explain the citation, but do not widen or
|
||||
replace the cited source reference.
|
||||
Context contract](../integrations/evidence-context.md). Decode its top-level
|
||||
source-unit array as a reading excerpt. Obtain authoritative citations and lane
|
||||
provenance from the normalized lane artifacts; the excerpt has neither and its
|
||||
nearby units do not widen a lane artifact's cited source reference.
|
||||
|
||||
A zero exit status may still report rejected outputs, warnings, or absent
|
||||
lanes. The caller decides which lane IDs are required for its own work and
|
||||
@@ -73,5 +73,5 @@ them. Treat the input, output bundle, cache, debug bundle, and captured process
|
||||
logs as potentially sensitive data. Apply the caller's access controls and
|
||||
retention policy, and avoid copying secrets into arguments, logs, or
|
||||
provenance records. An evidence-context artifact contains source-unit text and
|
||||
metadata, and selected lanes can cover most of an input; preserve and share it
|
||||
only when that source content is authorized for the recipient.
|
||||
metadata and can cover most of an input; preserve and share it only when that
|
||||
source content is authorized for the recipient.
|
||||
|
||||
@@ -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
|
||||
or aliases that normalize to another spell.
|
||||
|
||||
Spell extraction receives the effective catalog as deterministic canonical-name
|
||||
and alias pairs. An alias in the transcript selects its associated canonical
|
||||
name; the extractor is instructed to return that canonical spelling. The
|
||||
projection contains no catalog source metadata or provenance, and aliases
|
||||
remain recognition context rather than transcript evidence.
|
||||
|
||||
The overlay is a recognition aid only. The durable spell-artifact schema and
|
||||
source-evidence rules are defined by the
|
||||
[D&D spell artifact contract](dnd-spell-artifacts.md).
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Published Evidence Context
|
||||
|
||||
This contract defines the optional `source/evidence-context` artifact emitted
|
||||
by the production JSON output. Its configuration is owned by
|
||||
[Configuration](../config.md#module-bindings-and-validators); its logical-file
|
||||
discovery is owned by [Published JSON Output](json-output.md).
|
||||
by the production JSON output. It is a selected source-unit excerpt for
|
||||
convenient reading alongside normalized lane artifacts; it is not a second
|
||||
citation or provenance model. Its configuration is owned by
|
||||
[Configuration](../config.md#module-bindings-and-validators), and its
|
||||
logical-file discovery is owned by [Published JSON Output](json-output.md).
|
||||
|
||||
## Identity And Discovery
|
||||
|
||||
@@ -26,91 +28,80 @@ its absence means evidence publication was not enabled for that bundle.
|
||||
|
||||
## Payload
|
||||
|
||||
The v1 payload is a JSON object with required `source_id`, `source_digest`,
|
||||
`window_units`, `selected_lanes`, and `contexts` fields. `selected_lanes` and
|
||||
`contexts` are always arrays; an enabled configuration with no accepted direct
|
||||
evidence publishes `contexts: []`.
|
||||
The v1 payload is a top-level JSON array of generic source units. There is no
|
||||
wrapper, source-level metadata, context grouping, lane identifier, or evidence
|
||||
reference in the payload. An enabled configuration with no contributing
|
||||
accepted evidence publishes `[]`.
|
||||
|
||||
```json
|
||||
{
|
||||
"source_id": "session-alpha",
|
||||
"source_digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"window_units": 1,
|
||||
"selected_lanes": ["npc_registry", "spells"],
|
||||
"contexts": [
|
||||
{
|
||||
"context_ref": {
|
||||
"source_id": "session-alpha",
|
||||
"start_unit_id": 10,
|
||||
"end_unit_id": 20
|
||||
},
|
||||
"evidence_refs": [
|
||||
{
|
||||
"lane_id": "spells",
|
||||
"source_ref": {
|
||||
"source_id": "session-alpha",
|
||||
"start_unit_id": 10,
|
||||
"end_unit_id": 10
|
||||
}
|
||||
}
|
||||
],
|
||||
"units": [
|
||||
{
|
||||
"id": 10,
|
||||
"kind": "transcript_segment",
|
||||
"text": "Aria casts Cure Wounds.",
|
||||
"ref": {
|
||||
"source_id": "session-alpha",
|
||||
"start_unit_id": 10,
|
||||
"end_unit_id": 10
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"kind": "transcript_segment",
|
||||
"text": "The party regroups.",
|
||||
"ref": {
|
||||
"source_id": "session-alpha",
|
||||
"start_unit_id": 20,
|
||||
"end_unit_id": 20
|
||||
}
|
||||
}
|
||||
]
|
||||
[
|
||||
{
|
||||
"id": 10,
|
||||
"kind": "transcript_segment",
|
||||
"text": "Aria casts Cure Wounds.",
|
||||
"ref": {
|
||||
"source_id": "session-alpha",
|
||||
"start_unit_id": 10,
|
||||
"end_unit_id": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"kind": "transcript_segment",
|
||||
"text": "The party regroups.",
|
||||
"ref": {
|
||||
"source_id": "session-alpha",
|
||||
"start_unit_id": 20,
|
||||
"end_unit_id": 20
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Each context requires a `context_ref` object and `evidence_refs` and `units`
|
||||
arrays. `context_ref` identifies the first and last included unit. Each
|
||||
evidence entry contains a selected `lane_id` and an original `source_ref`. A
|
||||
unit uses the existing source-unit shape: required `id`, `kind`, `text`, and
|
||||
self `ref`, plus optional JSON-object `metadata`. Fixed payload objects reject
|
||||
unknown fields; unit metadata may contain application-defined JSON values.
|
||||
Each source unit has required `id`, `kind`, `text`, and self `ref` fields.
|
||||
`ref` contains `source_id`, `start_unit_id`, and `end_unit_id`, and both unit
|
||||
endpoints identify that unit's `id`. A unit may also contain source-owned
|
||||
`metadata`, an open-ended JSON object. Fixed unit and reference fields are
|
||||
strict: consumers must reject unknown fixed fields, malformed units, invalid
|
||||
self-references, units whose `source_id` differs from other units in the same
|
||||
excerpt, and a payload that is not the array described here.
|
||||
|
||||
## Citations And Context
|
||||
The excerpt preserves each selected unit exactly as represented by the
|
||||
validated generic source document. It does not add evidence-context-specific
|
||||
annotations or reshape source-owned metadata.
|
||||
|
||||
`evidence_refs` are the authoritative citations. They identify the direct
|
||||
references emitted by accepted normalized artifacts. `context_ref` and the
|
||||
units collection include those cited units plus nearby source units selected by
|
||||
the configured window. They are explanatory context, not widened citations.
|
||||
## Selection And Citations
|
||||
|
||||
Only accepted outputs from the configured lane allowlist contribute. Rejected,
|
||||
failed, absent, and lane-filtered outputs do not contribute. The artifact never
|
||||
contains raw input bytes, prompts, model responses, auxiliary reference
|
||||
content, credentials, or filesystem paths.
|
||||
The framework obtains direct source references only through typed evidence
|
||||
projections of accepted normalized artifacts in the configured lane allowlist.
|
||||
It validates each reference against the current source document, expands its
|
||||
range by `window_units` source-unit positions on each side, clamps at document
|
||||
boundaries, and takes the union of all expanded ranges. The output contains
|
||||
each selected source unit once in source-document position order, regardless
|
||||
of numeric unit IDs. Repeated references, overlapping windows, and citations
|
||||
from multiple lanes do not duplicate a unit. Rejected, failed, absent,
|
||||
inactive, and unselected lanes contribute nothing.
|
||||
|
||||
## Ordering And Compatibility
|
||||
Normalized lane artifacts remain authoritative for citations and for which lane
|
||||
cited a range. The excerpt has no lane attribution and must not be used to
|
||||
reconstruct it. Its included nearby units provide reading context only; they
|
||||
do not widen any citation in a lane artifact.
|
||||
|
||||
The selected lane allowlist is lexical. Contexts and units are in source
|
||||
document position order, not numeric unit-ID order. Direct evidence entries
|
||||
are deterministically ordered by lane and source reference. Overlapping or
|
||||
contiguous windows merge, and each source unit appears at most once in the
|
||||
resulting contexts.
|
||||
The excerpt contains at most every generic source unit once. It can therefore
|
||||
equal the complete generic source document when coverage is broad or the
|
||||
window is large. No byte-, token-, or compression-size guarantee is made, and
|
||||
the framework does not truncate the excerpt to meet an arbitrary size limit.
|
||||
|
||||
## Consumer Responsibilities And Data Handling
|
||||
|
||||
The artifact is additive to the JSON bundle and is not a lane payload,
|
||||
normalized-output count, checkpoint, or generated reference. Consumers that
|
||||
do not need it must tolerate the absent optional descriptor. Consumers that do
|
||||
use it should preserve the artifact and its schema identity with the run
|
||||
provenance, and should treat its source text and metadata as sensitive durable
|
||||
content.
|
||||
do not need it must tolerate an absent descriptor. Consumers that do use it
|
||||
should validate the descriptor and payload before use, retain the artifact with
|
||||
its schema identity when needed for a run record, and read citations from the
|
||||
corresponding normalized lane artifacts.
|
||||
|
||||
The excerpt contains source-unit text and source-owned metadata and is durable
|
||||
output. Treat it as sensitive source content, apply appropriate access controls
|
||||
and retention, and do not assume its selected form is materially smaller or
|
||||
less sensitive than the original input.
|
||||
|
||||
@@ -24,7 +24,7 @@ root for the logical discovery described here.
|
||||
| `warnings.json` | Accepted-output and run warnings. |
|
||||
| `lanes/<safe-lane-id>.json` | One normalized artifact payload for each lane. |
|
||||
| `chunk-map.json` | Optional accepted chunk map, when its export is enabled and available. |
|
||||
| `evidence-context.json` | Optional source-context artifact, when evidence publication is enabled. |
|
||||
| `evidence-context.json` | Optional selected source-unit excerpt, when evidence publication is enabled. |
|
||||
|
||||
JSON files are pretty-printed with a trailing newline. Lane payloads are
|
||||
accepted only when their media type is `application/json`.
|
||||
|
||||
@@ -84,13 +84,15 @@ replace it with a complete profile of the same ID from the configured PromptKit
|
||||
source. Deployment profile selection is documented in
|
||||
[Configuration](../config.md#promptkit-profiles).
|
||||
|
||||
The transcript assets have distinct consumers. Scene chunking consumes the
|
||||
complete-session `common-dnd-transcript-full.md`; extraction prompts consume
|
||||
the current-chunk `common-dnd-transcript-chunk.md`; and NPC, location, and item
|
||||
normalization consume `common-dnd-transcript-windows.md` alongside their
|
||||
candidate collections. Player, party, glossary, and compatible campaign
|
||||
references provide disambiguating context, not evidence. Reference material is
|
||||
canonically ordered before rendering so equivalent inputs remain stable.
|
||||
The D&D transcript assets have distinct consumers. Scene chunking consumes the
|
||||
complete-session `common-dnd-transcript-full.md`, while extraction prompts
|
||||
consume the current-chunk `common-dnd-transcript-chunk.md`. NPC, location, and
|
||||
item normalization instead mount the generic semantic-reconciliation
|
||||
candidate and transcript-window presentation assets. Player, party, glossary,
|
||||
and compatible campaign references provide disambiguating context only when
|
||||
declared by the active prompt; they never establish evidence. Reference
|
||||
material is canonically ordered before rendering so equivalent inputs remain
|
||||
stable.
|
||||
|
||||
Extraction prompts render the common system and identity messages first, then
|
||||
cached campaign references and the cached chunk transcript. Evidence policy and
|
||||
@@ -100,10 +102,11 @@ reusable extraction prefix identical while preserving the lane-specific suffix.
|
||||
|
||||
Scene chunking intentionally uses a different order: system, cached campaign
|
||||
references, uncached module instructions, then the final ephemeral full
|
||||
transcript. Entity normalization also has its own order: system, uncached
|
||||
module instructions, ephemeral reconciliation policy, uncached candidates, and
|
||||
final ephemeral transcript windows. These orders and cache controls are prompt
|
||||
behavior; change them only through the owning manifest and prompt declaration.
|
||||
transcript. Entity normalization also has its own order: D&D system, mandatory
|
||||
generic protocol, ephemeral domain semantic instructions, generic candidate
|
||||
presentation, and final ephemeral generic transcript windows. These orders and
|
||||
cache controls are prompt behavior; change them only through the owning
|
||||
manifest and prompt declaration.
|
||||
|
||||
## Evidence, Candidates, And Normalization
|
||||
|
||||
@@ -116,7 +119,8 @@ result.
|
||||
|
||||
Default chains keep responsibilities separate: structural validators assess the
|
||||
candidate, source-reference validators resolve cited ranges against the current
|
||||
source, durable-schema validation checks an approved representation, and
|
||||
source and require extraction evidence to stay within the current chunk,
|
||||
durable-schema validation checks an approved representation, and
|
||||
relatedness validators report advisory evidence concerns. The configured order
|
||||
is documented in
|
||||
[Configuration](../config.md#production-validator-keys-and-default-chains).
|
||||
@@ -132,12 +136,46 @@ canonicalize display values and evidence, use source-document order for stable
|
||||
output, and issue bounded warnings for changes or collapsed duplicates. NPC,
|
||||
item, and location registry normalizers are intentional exceptions: each first
|
||||
produces a deterministic candidate set, then may use a bounded structured-LLM
|
||||
proposal to reconcile identity groups. The proposal selects supplied
|
||||
descriptors—names with their candidate source references—not durable IDs.
|
||||
Request-local candidate keys may support resolution internally, but are never
|
||||
included in model input or output. Colliding descriptors are ineligible, and
|
||||
invalid or unusable proposals retain the deterministic result with retry or
|
||||
fallback diagnostics; the model does not directly replace durable records.
|
||||
proposal to reconcile identity groups.
|
||||
|
||||
## Semantic Registry Reconciliation
|
||||
|
||||
The three registry normalizers instantiate the domain-neutral
|
||||
`internal/framework/semanticreconcile` engine with default bounds. Each
|
||||
eligible candidate receives a contiguous, one-based `candidate_id` for that
|
||||
request. The model sees that handle, the candidate label and source-free
|
||||
evidence ranges, plus bounded transcript windows; it returns only duplicate
|
||||
groups of supplied handles and one supplied canonical handle per group. It
|
||||
never returns names, evidence, durable IDs, or replacement records. Identical
|
||||
labels and evidence remain independently selectable because their handles are
|
||||
distinct.
|
||||
|
||||
The generic core owns the mandatory handle protocol, candidate and transcript
|
||||
presentation, the private response schema, source-reference validation,
|
||||
candidate and combined-material limits, structured completion, proposal
|
||||
assessment, stable group ordering, and typed plan-application mechanics. The
|
||||
D&D prompt contributes its system message and registry-specific semantic
|
||||
instructions. The generic registrar registers the shared prompt and schema;
|
||||
the D&D registrar registers each consuming prompt and the fallback profile.
|
||||
|
||||
Fewer than two eligible candidates skips the LLM without a semantic warning.
|
||||
An exceeded bound also skips the call and preserves the deterministic
|
||||
preprocessed registry, adding the registry's bounded fallback warning. Invalid
|
||||
structured output or discarded proposal groups use the normalizer's existing
|
||||
retry contract; retry exhaustion preserves the safe deterministic or
|
||||
partially applied result and emits its bounded fallback warning. Provider,
|
||||
transport, cancellation, and context-material failures remain execution
|
||||
errors.
|
||||
|
||||
Application remains typed and registry-owned. All three policies select the
|
||||
canonical member's normalized display name, union member evidence in source
|
||||
order, preserve ungrouped records, and derive durable identity only after
|
||||
consolidation. NPC IDs derive from the final name. Item IDs also derive from
|
||||
the final name, and a typed guard prevents currency aliases from crossing
|
||||
denominations or mixing currency with non-currency records. Location IDs
|
||||
derive from the final name and final evidence, preserving same-name,
|
||||
parent/child, and distinct physical-place identities. Registry warning scopes,
|
||||
reason codes, and postconditions remain outside the generic core.
|
||||
|
||||
## Generated References And Grounding
|
||||
|
||||
|
||||
@@ -102,7 +102,8 @@ because it changes scheduling rather than execution semantics.
|
||||
Production construction creates one PromptKit client and wraps it in one
|
||||
scheduled client. The scheduler has a fixed, positive permit limit, serves
|
||||
queued calls in FIFO order, and removes a queued call when its context is
|
||||
cancelled. A granted permit is released exactly once on every completion path.
|
||||
cancelled. It rechecks the caller context after admission and before dispatch.
|
||||
A granted permit is released exactly once on every completion path.
|
||||
|
||||
The scheduled wrapper surrounds every `CompleteStructured` call, so concurrent
|
||||
lanes, pipeline retries, and LLM-backed validators share the same provider-call
|
||||
@@ -141,12 +142,28 @@ arrangement and its data-only boundary are defined by
|
||||
[ADR-0011](../adr/0011-centralize-llm-assets.md), rather than by this runtime
|
||||
guide.
|
||||
|
||||
The generic registrar is the sole production registration owner for the
|
||||
semantic-reconciliation default prompt and private response schema. The
|
||||
domain-neutral reconciliation package also exposes only its mandatory protocol
|
||||
and candidate/transcript presentation files for domain prompt manifests. D&D
|
||||
registry normalizers mount those files while retaining ownership and hashing
|
||||
of their D&D system message, semantic instructions, and complete prompt
|
||||
declaration. The response schema is therefore registered once even though
|
||||
several typed normalizers select it.
|
||||
|
||||
Mounted prompt assets determine a module's fingerprint. The fingerprint hashes
|
||||
only the module and shared files explicitly selected by its manifest, so an
|
||||
unrelated asset does not invalidate a checkpoint. Schema loaders validate JSON,
|
||||
attach identity and digest metadata, make defensive copies, and expose
|
||||
diagnostics without raw schema bytes.
|
||||
|
||||
Semantic-reconciliation normalizers extend this identity with the shared
|
||||
response-schema digest, framework policy version, and complete limit-policy
|
||||
digest. Their manifest metadata records the same content-free prompt, schema,
|
||||
policy, and limit identities together with domain identity and normalization
|
||||
policies. Request-local handles, source material, proposal content, and raw
|
||||
asset bytes are not checkpoint metadata.
|
||||
|
||||
Private response schemas validate a model transport envelope. They are not the
|
||||
durable artifact schema and should not be documented as an external wire
|
||||
contract. Durable formats and compatibility rules remain in the
|
||||
@@ -179,7 +196,9 @@ structured-output validation. The adapter reports an empty result, validation
|
||||
failure, empty structured body, or decode failure as
|
||||
`ErrInvalidStructuredOutput`, while retaining the returned raw bytes and debug
|
||||
material when they exist. Provider failures remain operational errors rather
|
||||
than output-validation failures.
|
||||
than output-validation failures. Apart from documented context, capacity, and
|
||||
invalid-output categories, provider error values and types do not cross the
|
||||
adapter error chain; callers receive only a credential-redacted diagnostic.
|
||||
|
||||
When PromptKit rejects backend admission before generation, the adapter maps
|
||||
`promptkit.ErrCapacityExceeded` to
|
||||
|
||||
@@ -42,14 +42,23 @@ generic source references and must use the codec's exact Go type. It does not
|
||||
interpret surrounding context or publish files; the pipeline validates the
|
||||
capability during preparation and the output boundary owns publication. See
|
||||
the [Published Evidence Context contract](../integrations/evidence-context.md)
|
||||
for the durable result.
|
||||
for the durable source-unit excerpt. Lane artifacts retain citation and lane
|
||||
provenance; the framework does not add either to that published excerpt.
|
||||
|
||||
An artifact family is broader than a module: it owns the cohesive domain
|
||||
feature across its artifact type, codec, stage modules, validators, prompt
|
||||
policy, schemas, identity helpers, and reference projections. An extractor and
|
||||
normalizer in one artifact family remain independently registered modules in
|
||||
their respective pipeline stages. This ownership vocabulary does not create a
|
||||
new registry or change the fixed pipeline.
|
||||
|
||||
## Production Composition
|
||||
|
||||
Production composition is intentionally split by family:
|
||||
|
||||
- The generic registrar provides the unit chunker, generic JSON validators,
|
||||
and JSON output encoder.
|
||||
JSON output encoder, and shared semantic-reconciliation prompt and response
|
||||
schema assets.
|
||||
- The Seriatim registrar provides the transcript input adapter. Its external
|
||||
input behavior is defined by the [Seriatim contract](../integrations/seriatim.md).
|
||||
- The D&D registrar provides its codecs, extractors, mergers, normalizers,
|
||||
@@ -60,6 +69,36 @@ The CLI owns the composition that invokes these registrars. A module package
|
||||
may register its own family but must not assemble the CLI or make framework
|
||||
packages depend on production extensions.
|
||||
|
||||
## Semantic Reconciliation
|
||||
|
||||
`internal/framework/semanticreconcile` is a domain-neutral strategy used by a
|
||||
typed normalize module; it is not itself a selectable stage module. A
|
||||
source-backed artifact-family normalizer projects its deterministic records
|
||||
into contextual candidates and owned typed record envelopes, supplies its
|
||||
chosen prompt identity and resolved LLM profile, and constructs an engine with
|
||||
explicit limits. The core filters invalid evidence, assigns contiguous
|
||||
request-local integer handles, renders bounded candidate and transcript
|
||||
materials, invokes the structured-completion boundary, and assesses the
|
||||
returned duplicate groups into a stable non-overlapping plan.
|
||||
|
||||
The normalizer then applies that plan through a typed `ApplicationPolicy`. The
|
||||
core preserves ungrouped records, contribution order, and provenance while the
|
||||
artifact family owns group guards, field and evidence consolidation, durable
|
||||
ID derivation, retry and fallback presentation, warnings, and postconditions.
|
||||
Request-local handles do not enter the typed value or durable artifact. Fewer
|
||||
than two eligible candidates skips model invocation; exceeding a candidate or
|
||||
combined-material bound preserves the deterministic result under the family's
|
||||
fallback policy. Provider, transport, cancellation, and context-construction
|
||||
failures remain execution errors.
|
||||
|
||||
The core supplies a conservative generic prompt and the single private
|
||||
response schema. A domain prompt may substitute its semantic instructions but
|
||||
mounts the core-owned protocol and candidate/transcript presentation assets.
|
||||
Prompt, schema, policy, and limit identities participate in manifest metadata
|
||||
and checkpoint fingerprints. The generic registrar owns production
|
||||
registration of those shared assets; a consuming domain registrar owns only
|
||||
its domain prompt.
|
||||
|
||||
## Adding Or Changing A Module
|
||||
|
||||
1. Choose the pipeline stage and the typed artifact boundary. Put external
|
||||
|
||||
@@ -29,6 +29,7 @@ physical state roots.
|
||||
| Generic models | **internal/core/source**, **internal/core/artifacts**, **internal/framework/contracts** | Source documents and chunks, manifests and provenance, plus typed artifact, reference, validation, output, and structured-completion contracts. |
|
||||
| Pipeline framework | **internal/framework/pipeline** | Registries, profile and reference resolution, typed preparation, validation, retry coordination, ordered execution, handoff, and result assembly. |
|
||||
| LLM and prompt runtime | **internal/framework/llm**, **internal/framework/promptfs** | Provider-neutral structured completions, scheduling, profile recording, prompt assets, schema registration, and credential-shaped-value redaction. |
|
||||
| Semantic reconciliation | **internal/framework/semanticreconcile** | Bounded source-backed candidate preparation, request-local handle proposals, deterministic assessment, typed plan application, and reconciliation identity metadata; see [Module Internals](modules.md#semantic-reconciliation) and [D&D Module Internals](dnd.md#semantic-registry-reconciliation). |
|
||||
| Embedded LLM content | **assets** | Read-only centralized LLM-facing content, scoped by its consuming package; see [LLM Runtime](llm.md#prompt-and-schema-assets) and [D&D Module Internals](dnd.md#prompt-construction). |
|
||||
| Runtime state | **internal/core/fileio**, **internal/core/debugbundle**, **internal/framework/checkpoint**, **internal/framework/chunkplan**, **internal/framework/chunkmap**, **internal/framework/debug** | Confined atomic files, debug bundles, checkpoint and chunk-plan state, accepted chunk maps, and pipeline-facing debug recording. |
|
||||
| Production extensions | **internal/modules/generic**, **internal/modules/seriatim**, **internal/modules/dnd** | Domain-neutral extensions, Seriatim input support, and D&D extraction families registered into the production catalog. |
|
||||
@@ -49,8 +50,9 @@ the CLI composition boundary.
|
||||
composition, and path safety.
|
||||
- [LLM Runtime](llm.md): structured completion, scheduling, prompt assets,
|
||||
profiles, and secret handling.
|
||||
- [Module Internals](modules.md): generic extension registration, module
|
||||
construction, validation, and reference mechanics.
|
||||
- [Module Internals](modules.md): generic extension registration, artifact
|
||||
families, module construction, semantic reconciliation, validation, and
|
||||
reference mechanics.
|
||||
- [D&D Module Internals](dnd.md): shared D&D extractor conventions, generated
|
||||
reference projections, and lane-specific exceptions. Durable D&D and
|
||||
Seriatim data shapes remain in the [integration contracts](../integrations/).
|
||||
|
||||
@@ -46,16 +46,18 @@ External reference materialization happens before preparation. The materializer
|
||||
checks that each slot is declared by the selected module, resolves a file path
|
||||
relative to the correct configuration or working-directory origin, reads
|
||||
UTF-8 text, verifies media type and size limits, and retains bounded
|
||||
provenance. A generated-artifact selector remains declared but has no bytes
|
||||
until its producing step completes.
|
||||
provenance. For a positive slot limit, it reads at most the limit plus one byte
|
||||
and rejects overflow before retaining content. A generated-artifact selector
|
||||
remains declared but has no bytes until its producing step completes.
|
||||
|
||||
Preparation is the construction boundary. It validates the resolved shape and
|
||||
registry set, clones the resolved data, then constructs the input adapter,
|
||||
chunker, stage-local validators, every typed lane, and output encoder with
|
||||
cloned options, references, and shared dependencies. It also collects stable
|
||||
checkpoint fingerprints. Missing registrations, incompatible typed entries,
|
||||
nil implementations, and constructor failures are reported before source
|
||||
parsing or any stage operation begins.
|
||||
chunker, stage-local validators, every typed lane, and output encoder. Each
|
||||
registered builder receives its own cloned build request immediately before its
|
||||
module-owned code runs. Preparation also collects stable checkpoint
|
||||
fingerprints. Missing registrations, incompatible typed entries, nil
|
||||
implementations, and constructor failures are reported before source parsing
|
||||
or any stage operation begins.
|
||||
|
||||
An output encoder can opt into source-evidence publication through its output
|
||||
policy. Preparation keeps the configured lane allowlist and active lanes
|
||||
@@ -115,9 +117,12 @@ for started workers, and prevents output encoding.
|
||||
|
||||
Every chunk, extract, merge, and normalize candidate passes its resolved
|
||||
validator chain. Validators receive immutable canonical input appropriate to
|
||||
their target: chunks, typed values, or serialized codec bytes. They may
|
||||
approve, approve with warnings, reject, or fail. A rejection is an ordinary
|
||||
pipeline result; a validator error is a framework error.
|
||||
their target: chunks, codec-decoded typed candidates, or serialized codec
|
||||
bytes. Each typed validator receives a newly decoded value from the one
|
||||
candidate serialization for that attempt, while serialized validators receive
|
||||
separately owned representation bytes and schema metadata. They may approve,
|
||||
approve with warnings, reject, or fail. A rejection is an ordinary pipeline
|
||||
result; a validator error is a framework error.
|
||||
|
||||
The runner applies the binding's retry policy around a stage operation and its
|
||||
complete validation chain. It preserves warnings only from the final accepted
|
||||
|
||||
@@ -47,6 +47,9 @@ The serialized
|
||||
they do not describe a current public state surface.
|
||||
|
||||
Ordered-step lane checkpoints include the step identity in their storage scope.
|
||||
Accepted step and lane identities are encoded injectively before becoming
|
||||
filesystem path components, while ordinary safe identifiers retain their
|
||||
readable paths.
|
||||
When a later lane consumes a generated artifact, its dependency fingerprints
|
||||
include the producer's artifact kind, complete schema identity, media type,
|
||||
canonical content digest, and size. Ordinary resume compares those fingerprints
|
||||
|
||||
@@ -110,9 +110,9 @@ are defined in [Accepted Chunk Map](integrations/chunk-map.md). An optional
|
||||
[evidence context](integrations/evidence-context.md) contains source-unit text
|
||||
and metadata. It is not a cache or debug artifact: retain it with the output
|
||||
bundle only for as long as consumers need it, and apply source-content access
|
||||
controls to the entire bundle. Selected lanes may collectively cite most of a
|
||||
transcript, so a broad allowlist can make the evidence artifact nearly as
|
||||
sensitive and large as the source itself.
|
||||
controls to the entire bundle. Its selected source-unit excerpt may include
|
||||
every source unit once when coverage is broad or its configured window is
|
||||
large, so do not assume a byte or token reduction or reduced sensitivity.
|
||||
|
||||
## Chunk-Plan Cache
|
||||
|
||||
|
||||
@@ -24,6 +24,12 @@ DAGs or a general workflow language. Every stage remains explicit; general
|
||||
chunking, merging, or normalization behavior must not be hidden inside an
|
||||
extractor.
|
||||
|
||||
A stage module is one configured implementation of one pipeline stage. An
|
||||
artifact family is the cohesive domain feature that owns an artifact across
|
||||
the explicit stages and supporting codecs, validators, prompts, identity
|
||||
rules, and reference projections. Artifact-family ownership does not combine
|
||||
stages or alter the fixed pipeline.
|
||||
|
||||
Input and chunking are pipeline-wide. Each selected artifact lane owns its
|
||||
extract, merge, and normalize stages, and the output stage aggregates the run's
|
||||
lane outcomes.
|
||||
@@ -39,6 +45,12 @@ implementations. Domain-neutral model and framework layers provide reusable
|
||||
policy, contracts, and orchestration. Concrete input, pipeline, output, and
|
||||
validation extensions depend inward on those generic layers.
|
||||
|
||||
Semantic reconciliation is one such domain-neutral framework mechanism. It
|
||||
prepares bounded source context, invokes a shared model-judgment protocol,
|
||||
validates proposals, and applies safe plans through typed policies supplied by
|
||||
the consuming artifact family. It does not own domain identity, durable IDs,
|
||||
warning semantics, or artifact construction rules.
|
||||
|
||||
Generic layers must not depend on production extensions. Concrete extensions
|
||||
must not compose the application or take ownership of process behavior. The
|
||||
current packages implementing these layers are inventoried in
|
||||
@@ -186,8 +198,13 @@ or domain-specific prompt logic.
|
||||
When a model selects an application entity, callers must supply a contextual
|
||||
selection and deterministically attach the opaque application identity whenever
|
||||
the selection resolves exactly. Models do not receive or reproduce opaque
|
||||
application identifiers; [ADR-0012](../adr/0012-resolve-opaque-entity-identifiers-deterministically.md)
|
||||
records the rationale and limited request-local-label exception.
|
||||
application identifiers. Semantic reconciliation may instead expose
|
||||
contiguous, one-based candidate handles that exist only for one request;
|
||||
deterministic code resolves them before typed application, and they never
|
||||
become durable identity. This is the approved request-local-label application
|
||||
of [ADR-0012](../adr/0012-resolve-opaque-entity-identifiers-deterministically.md)
|
||||
recorded by
|
||||
[ADR-0013](../adr/0013-use-request-local-candidate-handles-for-semantic-reconciliation.md).
|
||||
|
||||
LLM calls and other external operations accept cancellation and respect
|
||||
timeouts. Concurrency control belongs in shared runtime plumbing rather than in
|
||||
|
||||
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
@@ -1,516 +0,0 @@
|
||||
# Codebase Audit Plan
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines a repository-wide audit of Notarius for correctness,
|
||||
efficiency, maintainability, and clarity. The audit should identify concrete
|
||||
improvements without treating abstraction, fewer lines, or higher test coverage
|
||||
as goals in themselves.
|
||||
|
||||
The audit is intentionally separate from implementation. Its findings should
|
||||
be evidence-backed and sufficiently specific to support a later remediation
|
||||
roadmap, but the audit should not modify production code, tests, assets, or
|
||||
current-behavior documentation.
|
||||
|
||||
## Governing Principles
|
||||
|
||||
The audit must preserve the architecture and testing policies in
|
||||
`docs/policy/architecture.md` and `docs/policy/testing.md`.
|
||||
|
||||
In particular:
|
||||
|
||||
- Notarius remains a fixed, staged pipeline rather than a general workflow
|
||||
engine.
|
||||
- Generic framework packages must remain domain-neutral, and production
|
||||
modules must not acquire CLI or physical-state responsibilities.
|
||||
- Typed artifact boundaries, exact codec compatibility, deterministic ordering,
|
||||
whole-output validation, and generated-reference provenance are correctness
|
||||
properties, not incidental complexity to be optimized away.
|
||||
- The root `assets` package remains a content-only dependency leaf.
|
||||
- Shared helpers should protect demonstrated common semantics. Similar-looking
|
||||
code with different ownership, error policy, identity rules, or type contracts
|
||||
should remain separate.
|
||||
- Tests should protect durable behavior and meaningful risks. The audit should
|
||||
not recommend tests merely to increase coverage or freeze implementation
|
||||
details.
|
||||
- Efficiency claims must distinguish measured or structurally credible costs
|
||||
from cosmetic line-count reductions. Optimizing local CPU work that is
|
||||
insignificant beside an LLM call is low priority unless it also simplifies
|
||||
correctness or applies to large inputs.
|
||||
|
||||
## Audit Questions
|
||||
|
||||
Every audited area should be examined through the following questions.
|
||||
|
||||
### Correctness
|
||||
|
||||
- Are documented architecture invariants enforced at the correct boundary?
|
||||
- Can invalid configuration, incompatible artifact types, malformed references,
|
||||
or unavailable dependencies reach execution when they could be rejected
|
||||
during resolution or preparation?
|
||||
- Are nil, empty, absent, rejected, failed, and canceled states distinguished
|
||||
consistently?
|
||||
- Are stored or returned slices, maps, byte slices, options, metadata, source
|
||||
documents, references, and artifacts defensively owned where required?
|
||||
- Are public ordering, selected errors, warnings, and checkpoint decisions
|
||||
deterministic regardless of map or goroutine completion order?
|
||||
- Do cancellation, retry, validation, and partial-work semantics match their
|
||||
documented ownership?
|
||||
- Do checkpoint and chunk-plan identities include every semantic dependency and
|
||||
exclude scheduling-only or diagnostic state?
|
||||
- Can auxiliary references accidentally become source evidence, or can
|
||||
generated references bypass codec, schema, provenance, or step-order checks?
|
||||
- Can provider-specific values, credentials, or source content escape through
|
||||
errors, manifests, debug summaries, cache state, or logs?
|
||||
- Do schemas, codecs, candidate decoders, normalizers, and validators agree on
|
||||
the exact durable contract without silently accepting incompatible shapes?
|
||||
|
||||
### Duplication And Shared Mechanics
|
||||
|
||||
- Which exact or near-duplicate implementations express the same invariant and
|
||||
failure policy?
|
||||
- Has duplicated code already drifted in naming, nil handling, canonicalization,
|
||||
metadata, fingerprints, validation, or diagnostics?
|
||||
- Would a helper have a natural owner and a smaller, clearer contract than the
|
||||
duplicated callers?
|
||||
- Can an extraction preserve static typing and package ownership, or would it
|
||||
require reflection, `any`, callbacks with many policy parameters, or a
|
||||
domain-neutral package importing domain concepts?
|
||||
- Is repeated code required by a small interface adapter or typed registration
|
||||
boundary and therefore clearer when left explicit?
|
||||
|
||||
As a default heuristic, prioritize a shared helper when identical semantics
|
||||
appear in three or more production sites, or in two sites where divergence
|
||||
would create a meaningful correctness risk. Do not use that heuristic as a
|
||||
quota: one substantial duplicate may warrant extraction, while widespread
|
||||
one-line interface methods may not.
|
||||
|
||||
### Simplicity And Idiomatic Go
|
||||
|
||||
- Does a function combine orchestration, policy, transformation, persistence,
|
||||
and reporting that could be separated along existing ownership boundaries?
|
||||
- Are repeated scans, sorts, conversions, clones, encodes, or decodes doing work
|
||||
that can safely occur once?
|
||||
- Are intermediate representations necessary, or can a value be validated,
|
||||
canonicalized, and mapped in one comprehensible pass?
|
||||
- Are maps, sets, stable sorts, generics, standard-library helpers, and error
|
||||
wrapping used idiomatically?
|
||||
- Are abstractions earning their complexity, or are interfaces, option layers,
|
||||
wrappers, aliases, compatibility paths, and private types left over after a
|
||||
completed migration?
|
||||
- Are there unreachable error branches, redundant fingerprints or digests,
|
||||
duplicated sources of truth, or accessors used only by tests?
|
||||
- Can a smaller implementation preserve exact observable behavior and safety
|
||||
properties?
|
||||
|
||||
### Explanatory Comments
|
||||
|
||||
Comments should be recommended where the code is necessarily complex because
|
||||
it preserves a non-obvious invariant. Good candidates include:
|
||||
|
||||
- concurrency coordination, cancellation, and stable error selection;
|
||||
- checkpoint identity, reuse, forced recomputation, and dependency invalidation;
|
||||
- typed erasure and restoration at framework boundaries;
|
||||
- generated-reference ordering and provenance;
|
||||
- canonicalization and identity resolution where registry evidence differs
|
||||
from occurrence evidence;
|
||||
- prompt ordering or input identity required for backend caching; and
|
||||
- path confinement, atomic publication, redaction, or terminal error precedence.
|
||||
|
||||
Recommend comments that explain *why* a step or ordering constraint exists and
|
||||
what would break if it changed. Do not recommend comments that narrate syntax,
|
||||
repeat a function name, duplicate current-behavior documentation, or preserve
|
||||
implementation history.
|
||||
|
||||
### Tests
|
||||
|
||||
- Is each consequential invariant protected at the narrowest stable boundary?
|
||||
- Are concurrency, cancellation, retries, recovery, compatibility, path safety,
|
||||
and data-integrity behavior credibly exercised?
|
||||
- Do higher-level contract tests duplicate lower-level cases without adding
|
||||
integration confidence?
|
||||
- Are tests coupled to private constants, helper shape, exact prose, full error
|
||||
strings, or collaborator choreography rather than behavior?
|
||||
- Can repetitive fixtures or fakes be simplified without creating a test
|
||||
framework more complex than the tests?
|
||||
- Would a focused fuzz test, race test, or package-level invariant test protect
|
||||
a realistic risk better than several example tests?
|
||||
|
||||
## Evidence And Finding Standards
|
||||
|
||||
Static metrics and textual similarity are discovery aids, not findings. A long
|
||||
function may be a clear linear coordinator; identical methods may be useful
|
||||
typed adapters. Every reported finding must include:
|
||||
|
||||
1. a concise title and severity;
|
||||
2. exact files and symbols;
|
||||
3. the observed behavior or structural evidence;
|
||||
4. the correctness, efficiency, maintenance, or comprehension impact;
|
||||
5. a concrete recommended direction;
|
||||
6. important invariants the remediation must preserve;
|
||||
7. focused validation that would demonstrate success; and
|
||||
8. whether the recommendation is independent or should be grouped with another
|
||||
finding.
|
||||
|
||||
Use these severities:
|
||||
|
||||
- **High:** a credible risk of corrupt output, unsafe state handling, secret
|
||||
exposure, stale reuse, deadlock, nondeterminism, or violated external
|
||||
contract.
|
||||
- **Medium:** a plausible behavioral defect, meaningful wasted work on common
|
||||
paths, or complexity/duplication likely to cause future correctness drift.
|
||||
- **Low:** a contained simplification, small efficiency improvement, dead code,
|
||||
naming issue, or missing explanation with no current behavioral failure.
|
||||
|
||||
The audit should explicitly record examined areas with no findings. This makes
|
||||
coverage visible and prevents later agents from repeatedly rediscovering the
|
||||
same safe design.
|
||||
|
||||
## Repository Areas
|
||||
|
||||
### 1. Architecture And Dependency Boundaries
|
||||
|
||||
Inspect `docs/policy/architecture.md`, `docs/adr/`, `docs/internal/overview.md`,
|
||||
package imports, module registrars, and the CLI composition root.
|
||||
|
||||
Look for:
|
||||
|
||||
- framework or core code depending on production modules;
|
||||
- modules depending on CLI, physical roots, or provider-specific types;
|
||||
- domain knowledge placed in generic helpers;
|
||||
- duplicated registries or composition policy outside the owning registrar;
|
||||
- abstractions that turn the fixed pipeline into an implicit general graph; and
|
||||
- current code that no longer matches an accepted ADR or documented invariant.
|
||||
|
||||
Graph-reported cross-layer calls must be traced before being classified because
|
||||
tests and interface implementations can resemble dependency inversions without
|
||||
creating a production import violation.
|
||||
|
||||
### 2. Configuration And CLI Composition
|
||||
|
||||
Inspect `internal/core/config`, `internal/cli`, configuration parsing and
|
||||
redaction tests, profile construction, session derivation, catalog assembly,
|
||||
reference overrides, run-result handling, terminal reporting, and maintained
|
||||
example contract tests.
|
||||
|
||||
Pay particular attention to the currently dense paths around
|
||||
`runPipelineCommand`, configuration profile validation, selected reference
|
||||
targets, recomputation policy, and option normalization. Determine whether
|
||||
their complexity reflects necessary composition or mixed responsibilities that
|
||||
can be separated without moving policy into the framework.
|
||||
|
||||
Verify:
|
||||
|
||||
- file, environment, CLI, pipeline, binding, and prompt-default precedence;
|
||||
- consistent strict option and unknown-field handling;
|
||||
- session identity independence from references and pipeline-local changes;
|
||||
- effective profile and runtime fingerprint consistency;
|
||||
- redaction before errors or debug/manifest boundaries;
|
||||
- output publication only after framework success; and
|
||||
- one guarded terminalization path that preserves the primary failure.
|
||||
|
||||
### 3. Pipeline Resolution, Preparation, And Typed Registries
|
||||
|
||||
Inspect `internal/framework/pipeline/profile.go`, `prepare.go`, registry files,
|
||||
`options.go`, `references.go`, `handoff.go`, `construction.go`, typed contracts,
|
||||
and their focused tests.
|
||||
|
||||
This area deserves a dedicated pass because the current graph identifies
|
||||
`ResolvePipeline`, generated-binding validation, reference-target resolution,
|
||||
and generated-reference construction as high-complexity or high-fan-in code.
|
||||
|
||||
Verify:
|
||||
|
||||
- static failures occur before source parsing;
|
||||
- selected and unselected lanes do not contaminate each other's requirements;
|
||||
- stage defaults and overrides have one canonical resolution path;
|
||||
- typed registration and private erasure cannot panic or accept near-matching
|
||||
artifact types;
|
||||
- generated bindings reject cycles, forward references, ambiguity, wrong kinds,
|
||||
and missing accepted normalized producers;
|
||||
- materialized reference bytes and options are cloned and bounded; and
|
||||
- resolved composition and prepared fingerprints include the complete semantic
|
||||
policy exactly once.
|
||||
|
||||
Compare input, chunker, extractor, merger, normalizer, output, validator, codec,
|
||||
evidence-projector, and validator-chain registries for shared mechanics and
|
||||
intentional differences. Repeated typed registration code is a candidate only
|
||||
if a helper can retain useful compile-time guarantees and stage-specific
|
||||
diagnostics.
|
||||
|
||||
### 4. Pipeline Execution, Validation, Retry, And Concurrency
|
||||
|
||||
Inspect `runner*.go`, `typed_execution.go`, `runner_typed.go`,
|
||||
`runner_concurrent.go`, validation-chain execution, normalize retry behavior,
|
||||
synchronized collaborators, and the concurrency, cancellation, retry, debug,
|
||||
and checkpoint tests.
|
||||
|
||||
Trace complete paths rather than reviewing helper files in isolation:
|
||||
|
||||
- source and chunk-plan selection through chunk validation;
|
||||
- deterministic chunk-first/lane-second dispatch;
|
||||
- lane extraction through merge and normalize continuations;
|
||||
- rejection versus framework-error propagation;
|
||||
- cancellation before dispatch, while queued, and while running;
|
||||
- retry attempts and warning retention;
|
||||
- stable error selection after concurrent completion;
|
||||
- checkpoint hydration back into typed execution; and
|
||||
- output suppression after a framework error.
|
||||
|
||||
Look for goroutine leaks, unbounded work, lock-order risks, double release or
|
||||
double recording, races on shared result state, unnecessary serialization,
|
||||
and repeated canonicalization. Comments are especially valuable here when they
|
||||
explain ordering or cancellation invariants that are not apparent from local
|
||||
control flow.
|
||||
|
||||
### 5. State, Checkpoints, Chunk Plans, Debugging, And File Safety
|
||||
|
||||
Inspect `internal/framework/checkpoint`, `chunkplan`, `chunkmap`, `debug`,
|
||||
`evidencecontext`, `internal/core/fileio`, `debugbundle`, and their CLI
|
||||
composition.
|
||||
|
||||
Verify:
|
||||
|
||||
- narrow path validation and symlink-resistant confinement;
|
||||
- atomic writes and recoverable explicit cleanup;
|
||||
- separation of checkpoint recording, resume loading, chunk-plan caching, and
|
||||
debug capture;
|
||||
- canonical encoding before content identity is trusted;
|
||||
- complete but non-secret checkpoint fingerprints;
|
||||
- correct ordinary-resume and selective-recompute behavior;
|
||||
- producer dependency invalidation across ordered steps;
|
||||
- immutable hydration and no aliasing with stored bytes;
|
||||
- debug data never influencing execution or reuse; and
|
||||
- terminal persistence failures never obscuring the primary error.
|
||||
|
||||
Review the repeated extract/merge/normalize recorder and loader methods, path
|
||||
component validators in multiple state packages, and clone/encode/decode paths.
|
||||
Determine which repetition is a clear stage adapter and which can share a
|
||||
private primitive without weakening reason-code ownership or diagnostics.
|
||||
|
||||
### 6. LLM Runtime, Prompt Filesystems, And Assets
|
||||
|
||||
Inspect `internal/framework/llm`, `promptfs`, the PromptKit integration,
|
||||
scheduler, profile-source construction, prompt/schema registries, root
|
||||
`assets`, module prompt manifests, and relevant D&D shared assets.
|
||||
|
||||
Verify:
|
||||
|
||||
- every provider call passes through the shared scheduler and cancellation
|
||||
removes queued calls safely;
|
||||
- PromptKit and Notarius concurrency limits compose as documented;
|
||||
- profile inspection and runtime use identical source precedence;
|
||||
- session IDs, profile-source fingerprints, prompt fingerprints, and schema
|
||||
fingerprints reflect the intended semantic inputs;
|
||||
- secrets and provider-specific error types do not cross the boundary;
|
||||
- prompt inputs and private outputs do not expose opaque entity IDs;
|
||||
- prompt ordering, stable prefixes, and cache controls remain intentional;
|
||||
- schema loaders and filesystem adapters validate once and return defensive
|
||||
data; and
|
||||
- the root assets package contains no business logic.
|
||||
|
||||
Compare the LLM asset registry and prompt-filesystem adapters for duplicated
|
||||
filesystem behavior. Review repeated prompt/schema loader and metadata code in
|
||||
module packages, but reject an extraction that would centralize domain prompt
|
||||
ownership or make unrelated assets share one invalidation boundary.
|
||||
|
||||
### 7. Generic And Seriatim Modules
|
||||
|
||||
Inspect `internal/modules/generic` and `internal/modules/seriatim`, including
|
||||
module specs, option decoding, chunk planning, input translation, validators,
|
||||
output encoding, evidence-context publication, registration, and tests.
|
||||
|
||||
Verify that:
|
||||
|
||||
- external Seriatim details end at the input boundary;
|
||||
- generic chunking and output remain domain-neutral;
|
||||
- chunk plans and source units preserve source-addressed invariants;
|
||||
- output logical names are safe and deterministic;
|
||||
- output options do not bypass preparation-time compatibility checks; and
|
||||
- option decoding is strict, small, and consistent with configuration
|
||||
validation.
|
||||
|
||||
The graph flags generic integer option parsing and JSON output policy decoding
|
||||
as relatively complex. Examine whether that is inherent strict decoding or an
|
||||
opportunity for a smaller typed parser with equally precise diagnostics.
|
||||
|
||||
### 8. D&D Domain Model, Codecs, And Shared Helpers
|
||||
|
||||
Inspect `internal/modules/dnd` domain types, codecs, candidate decoders,
|
||||
identity packages, registries, shared source-reference helpers, diagnostics,
|
||||
registry resolution, entity reconciliation, mergers, registrar, and assets.
|
||||
|
||||
Compare all ten current artifact families. Build a convention matrix covering:
|
||||
|
||||
- module specs and execution classes;
|
||||
- constructor and option behavior;
|
||||
- manifest metadata and checkpoint fingerprints;
|
||||
- response-schema loading and private-versus-durable types;
|
||||
- source-reference conversion, canonicalization, ordering, and deduplication;
|
||||
- nil versus present-empty output;
|
||||
- codecs and strict JSON behavior;
|
||||
- registry lookup, identity derivation, immutable projections, and resolution;
|
||||
- normalizer retry/fallback behavior;
|
||||
- validators and default chains; and
|
||||
- registration, prompt assets, and documentation ownership.
|
||||
|
||||
The graph reports many exact similarities among codec `Decode` methods,
|
||||
fingerprint/metadata methods, registry extractors, identity helpers, occurrence
|
||||
normalizers, and validators. Treat these as a prioritized review list, not an
|
||||
instruction to create one generic D&D engine. A worthwhile helper must preserve
|
||||
domain-specific identity, evidence, kind ordering, validation, diagnostics,
|
||||
and artifact typing.
|
||||
|
||||
### 9. D&D Extraction And Normalization Flows
|
||||
|
||||
Trace each lane end to end rather than auditing only similarly named files:
|
||||
|
||||
- spells;
|
||||
- NPC registry and NPC occurrences;
|
||||
- combat turns and enemy events;
|
||||
- item registry and item occurrences;
|
||||
- scene descriptions; and
|
||||
- location registry and location occurrences.
|
||||
|
||||
For registry/occurrence pairs, verify the complete semantic boundary: the model
|
||||
uses contextual evidence, deterministic code attaches opaque identity, registry
|
||||
evidence does not become occurrence evidence, and unresolved or ambiguous
|
||||
selections fail according to lane policy.
|
||||
|
||||
Review whether any lane resolves or canonicalizes the same entity, source
|
||||
reference, or response twice; constructs unnecessary intermediate response
|
||||
forms; performs repeated sorts or scans; or retains transitional paths. Compare
|
||||
registry normalizers and occurrence normalizers for genuinely identical
|
||||
mechanics, while keeping currency, same-name location, NPC ambiguity, spell
|
||||
catalog, scene eligibility, and combat-specific policy with their owners.
|
||||
|
||||
### 10. Test Suite And Comment Coverage
|
||||
|
||||
Review the tests associated with every preceding area after understanding the
|
||||
production contracts. This should be a cross-cutting pass, not a request to add
|
||||
tests for every flagged function.
|
||||
|
||||
Identify:
|
||||
|
||||
- consequential unprotected invariants;
|
||||
- duplicated policy assertions across layers;
|
||||
- brittle tests coupled to internal constants, prompt prose, or private helper
|
||||
shape;
|
||||
- oversized test harnesses and repeated fixtures that obscure intent;
|
||||
- race-sensitive code not exercised under `-race`;
|
||||
- parsers, canonicalizers, and path handlers where fuzzing would address a real
|
||||
input-space risk; and
|
||||
- complex production code whose tests reveal an unclear ownership boundary.
|
||||
|
||||
Also identify necessarily complex symbols that lack a concise invariant-level
|
||||
comment. Comment recommendations should name the exact symbol and the fact the
|
||||
comment should explain; “add more comments” is not an actionable finding.
|
||||
|
||||
## Efficiency Evaluation
|
||||
|
||||
The audit should consider both runtime and maintenance efficiency.
|
||||
|
||||
For runtime efficiency, examine algorithmic behavior relative to realistic
|
||||
input dimensions: source units, chunks, lanes, references, artifacts, registry
|
||||
records, checkpoint files, and prompt assets. Prioritize repeated full-input
|
||||
passes, nested linear lookup, unnecessary JSON round trips, repeated hashing,
|
||||
large defensive copies at adjacent ownership boundaries, and serialization on
|
||||
concurrent hot paths. Preserve a defensive copy when it establishes ownership;
|
||||
removing it solely to reduce allocation is not an improvement.
|
||||
|
||||
For maintenance efficiency, prioritize repeated policy, parallel type systems,
|
||||
duplicated error classification, scattered defaults, and migrations that left
|
||||
two ways to perform the same operation. Boilerplate is costly only when it can
|
||||
drift or obscures the semantic core. Small explicit typed adapters can be more
|
||||
maintainable than a generic abstraction.
|
||||
|
||||
Do not recommend caching, pooling, concurrency, or a benchmark without naming
|
||||
the workload and risk it addresses. Add a benchmark only when a proposed
|
||||
optimization concerns a repeatable local path and the result would influence
|
||||
the decision.
|
||||
|
||||
## Audit Method
|
||||
|
||||
Each area should use the same method:
|
||||
|
||||
1. Read its architecture/internal documentation and focused tests.
|
||||
2. Map public/package contracts and trace the main call paths.
|
||||
3. Inspect high-fan-in, high-cognitive-complexity, nested-loop, and repeated-
|
||||
conversion symbols.
|
||||
4. Review exact and near-duplicate code side by side, including callers and
|
||||
failure semantics.
|
||||
5. Check dependency direction, ownership, aliasing, deterministic order,
|
||||
cancellation, and error classification.
|
||||
6. Compare tests with the risks owned at that layer.
|
||||
7. Record findings and inspected-with-no-finding areas before moving on.
|
||||
8. Run focused read-only validation when it can confirm or refute a suspected
|
||||
problem.
|
||||
|
||||
Prefer the repository knowledge graph for symbol discovery, call tracing, and
|
||||
similarity candidates. Use textual search for literals, diagnostics, config
|
||||
keys, asset content, and stale names. Read complete implementations and tests
|
||||
before reporting a metric-derived candidate.
|
||||
|
||||
## Execution Sequence
|
||||
|
||||
This document owns audit scope, questions, evidence standards, and the quality
|
||||
bar. [Staged Codebase Audit Sequence](audit-sequence.md) is the sole canonical
|
||||
owner of prompt order, stage boundaries, per-stage reading, validation commands,
|
||||
and acceptance criteria. Do not derive or maintain a second sequence here.
|
||||
|
||||
The audit is executed as bounded prompts and writes its accumulated findings to
|
||||
`docs/roadmap/audit.md`. Later stages must build on and reconcile earlier
|
||||
evidence rather than concatenate independent reports. Implementation and
|
||||
roadmap retirement remain separate work after maintainers review the completed
|
||||
audit.
|
||||
|
||||
## Baseline And Validation
|
||||
|
||||
Before the first audit stage, record the commit under review and require a clean
|
||||
worktree. Refresh the code knowledge graph so renamed or deleted code does not
|
||||
produce false findings. Run the normal offline baseline:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./cmd/notarius
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Run `go test -race` for packages with concurrency or mutable shared state,
|
||||
especially `internal/framework/pipeline`, `internal/framework/llm`, state
|
||||
packages, and D&D registries. A repository-wide race run is appropriate for
|
||||
final verification if its cost remains reasonable.
|
||||
|
||||
Optional diagnostic commands should be used only when relevant:
|
||||
|
||||
- `go test -count=1` to rule out cache-masked failures;
|
||||
- `go test -shuffle=on` to detect order coupling;
|
||||
- focused fuzzing for existing or newly justified fuzz targets; and
|
||||
- focused benchmarks or profiles for a specific efficiency finding.
|
||||
|
||||
The audit itself should not change tests to make the baseline pass. Record any
|
||||
pre-existing failure and distinguish it from an audit finding.
|
||||
|
||||
## Deliverable Quality Bar
|
||||
|
||||
The completed audit should:
|
||||
|
||||
- cover every repository area listed above;
|
||||
- distinguish defects from refactoring opportunities and comment requests;
|
||||
- distinguish credible performance costs from aesthetic simplification;
|
||||
- identify intentional duplication that should remain explicit;
|
||||
- avoid recommendations that violate dependency direction or weaken typing;
|
||||
- cite exact evidence and preserve named invariants for every finding;
|
||||
- consolidate root causes rather than report many symptoms;
|
||||
- rank independent work so a later implementation plan can stage it safely;
|
||||
- recommend no code change whose expected benefit is smaller than its added
|
||||
abstraction or test-maintenance cost; and
|
||||
- leave implementation and roadmap retirement to later work.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None are required to begin the audit. If a later stage cannot determine whether
|
||||
behavior is intentional from code, tests, policies, ADRs, or current
|
||||
documentation, it should record the uncertainty and a recommended resolution
|
||||
rather than silently treating preference as a defect.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,350 +0,0 @@
|
||||
# Contextual Entity Grounding
|
||||
|
||||
## Purpose
|
||||
|
||||
Notarius should use an LLM for semantic interpretation of source evidence, not
|
||||
for referential-integrity work that deterministic code can perform more
|
||||
reliably. D&D prompts must therefore stop requiring models to reproduce opaque
|
||||
machine identifiers such as hash-derived entity IDs. Models should identify
|
||||
entities through human-readable, evidence-grounded context, after which
|
||||
Notarius resolves the selection and attaches the canonical durable identity.
|
||||
|
||||
This roadmap defines the policy, affected D&D prompt families, and intended
|
||||
end state. The ordered work needed to reach that state is maintained in
|
||||
[Implementation Plan](implementation.md).
|
||||
|
||||
## User Intent
|
||||
|
||||
The change has two goals:
|
||||
|
||||
- prevent otherwise useful model responses from failing because a long,
|
||||
non-semantic string was copied incorrectly; and
|
||||
- avoid spending prompt space and model effort on exact-copy work that provides
|
||||
no semantic value.
|
||||
|
||||
The policy is not a ban on identifiers. Durable artifacts may continue to use
|
||||
application-owned IDs, and prompts may continue to request source-unit ranges
|
||||
that locate evidence. The policy governs which identity work is assigned to
|
||||
the model.
|
||||
|
||||
## Policy
|
||||
|
||||
An LLM-facing prompt input or private response schema must not require a model
|
||||
to reproduce an opaque machine identifier when Notarius can establish the same
|
||||
association deterministically.
|
||||
|
||||
Opaque machine identifiers include cryptographic hashes, UUIDs, digests,
|
||||
database keys, durable entity IDs, and other tokens whose characters do not
|
||||
carry source-grounded meaning for the model. These values may remain in
|
||||
application state, provenance, diagnostics, checkpoints, and durable artifact
|
||||
contracts, but should be omitted from model-visible material when they do not
|
||||
help the model make a semantic decision.
|
||||
|
||||
The intended responsibility boundary is:
|
||||
|
||||
- the model decides which contextual entity is supported by the supplied
|
||||
evidence and returns the bounded semantic facts requested by the module;
|
||||
- the calling module validates that the contextual selection resolves to
|
||||
exactly one supplied candidate;
|
||||
- deterministic code supplies the canonical display value and durable entity
|
||||
ID; and
|
||||
- existing validators continue to enforce referential integrity at later
|
||||
artifact boundaries.
|
||||
|
||||
Transcript `start_unit_id` and `end_unit_id` values are permitted. They are
|
||||
contextual source coordinates and form part of the evidence contract rather
|
||||
than arbitrary identity tokens. Prompt IDs, schema IDs, fingerprints, session
|
||||
IDs, and digests may also remain in runtime metadata that the model is not
|
||||
asked to reproduce.
|
||||
|
||||
Short request-local labels are a narrowly permitted fallback only when a
|
||||
contextual selector cannot uniquely represent the available choices without
|
||||
unreasonable prompt cost. Such a label must be compact, scoped to one request,
|
||||
validated against the supplied candidate set, and never reused as a durable
|
||||
identity. Current D&D occurrence and reconciliation prompts should be designed
|
||||
without this exception; adopting it later requires a concrete demonstrated
|
||||
need and documented rationale.
|
||||
|
||||
## Current State
|
||||
|
||||
The initial NPC, item, and location registry extractors already follow the
|
||||
desired pattern: the model returns contextual names and evidence, and Notarius
|
||||
derives durable IDs afterward. Spells, combat turns, and enemy events use
|
||||
contextual actor names rather than requiring hash-derived NPC IDs.
|
||||
|
||||
Two current prompt families diverge from that pattern:
|
||||
|
||||
1. `dnd/npc-occurrences`, `dnd/item-occurrences`, and
|
||||
`dnd/location-occurrences` place durable registry IDs in model-visible
|
||||
projections and require the private LLM response to repeat those IDs.
|
||||
2. NPC-, item-, and location-registry normalization use the shared entity
|
||||
reconciliation prompt, which labels candidates with opaque
|
||||
`candidate-000001`-style keys and requires the model to copy those keys into
|
||||
duplicate-group proposals.
|
||||
|
||||
The durable occurrence artifacts correctly retain canonical entity IDs. The
|
||||
problem is the private model transport contract, not the published artifact
|
||||
contract.
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### Model proposals and durable artifacts
|
||||
|
||||
Private LLM response types must express contextual semantic proposals rather
|
||||
than reuse the durable artifact type when that type contains an opaque entity
|
||||
ID. The extractor maps a validated private response into the existing durable
|
||||
artifact only after identity resolution succeeds.
|
||||
|
||||
No affected durable artifact kind, media type, schema ID, schema version, or
|
||||
JSON field changes as part of this work. NPC, item, and location occurrence
|
||||
artifacts continue to publish their exact canonical ID/name pair. Registry
|
||||
artifacts likewise retain their IDs and evidence.
|
||||
|
||||
The private schemas and prompt declarations may remain at their current `v1`
|
||||
identities because Notarius is pre-release and these are not external
|
||||
contracts. Their content hashes, mapping-policy fingerprints, and affected
|
||||
prompt fingerprints must change so incompatible checkpoints are not reused.
|
||||
|
||||
### NPC occurrence grounding
|
||||
|
||||
The NPC occurrence prompt receives an ordered names-only projection of the
|
||||
normalized NPC registry. Its private response contains the canonical NPC name,
|
||||
occurrence kind, and current-transcript source ranges, but no `npc_id`.
|
||||
|
||||
The extractor resolves the returned name under the existing NPC comparison
|
||||
policy. Resolution must produce exactly one registry entry. It then writes that
|
||||
entry's canonical display name and durable ID into the `dnd.NPCOccurrence`.
|
||||
An unknown or ambiguous selection invalidates the extraction operation; the
|
||||
extractor must not guess, use fuzzy matching, silently omit the record, or
|
||||
accept a partial response.
|
||||
|
||||
### Item occurrence grounding
|
||||
|
||||
The item occurrence prompt receives an ordered names-only projection of the
|
||||
normalized item registry. Its private response contains the canonical item
|
||||
name, occurrence kind, kind-specific fields, and current-transcript source
|
||||
ranges, but no `item_id`.
|
||||
|
||||
The extractor resolves the returned name under the existing item comparison
|
||||
and identity policies. Resolution must produce exactly one registry entry,
|
||||
whose canonical name and durable ID are attached deterministically. Unknown or
|
||||
ambiguous selections invalidate the complete extraction operation rather than
|
||||
being guessed, repaired by similarity, or dropped.
|
||||
|
||||
### Location occurrence grounding
|
||||
|
||||
Location identity cannot always be resolved from a display name alone: the
|
||||
current registry intentionally permits same-name locations with distinct
|
||||
source anchors. The location occurrence prompt must therefore receive a
|
||||
contextual registry descriptor that contains the canonical display name plus
|
||||
the minimum source-grounded registry evidence needed to distinguish same-name
|
||||
records. It must not contain the durable `location:sha256:...` value.
|
||||
|
||||
The private response uses two required selector fields: `name` and
|
||||
`registry_refs`. For a comparison-unique canonical name, `registry_refs` is an
|
||||
empty array and Notarius resolves the name under the location comparison
|
||||
policy. For a name shared by multiple registry records, `registry_refs`
|
||||
contains that record's complete canonically ordered registry ranges as
|
||||
`start_unit_id` and `end_unit_id` pairs, without `source_id`.
|
||||
|
||||
Every model-facing registry entry uses one fixed shape with required `name`,
|
||||
`registry_refs`, and `context` fields. `context` is an array of strict objects
|
||||
containing only `unit_id` and `text`. Comparison-unique entries use empty
|
||||
`registry_refs` and `context` arrays. Same-name entries use the complete
|
||||
registry-range selector and the bounded context described below. The model
|
||||
returns only `name` and `registry_refs`; it does not reproduce `context`.
|
||||
|
||||
For same-name groups, the projection also supplies bounded transcript units
|
||||
covered by each record's registry ranges so the model receives meaningful
|
||||
identity context rather than coordinates alone. Those ranges must resolve
|
||||
against the current source document, and the resulting contextual selectors
|
||||
must be unique. An invalid range or selector collision prevents the LLM call
|
||||
and fails the operation. Unique-name entries do not repeat registry ranges or
|
||||
context in the selector, preserving compatibility with a valid registry from
|
||||
another source when the name alone is unambiguous.
|
||||
|
||||
The private response separately supplies current-transcript `source_refs` that
|
||||
prove the occurrence. Registry identity evidence and occurrence evidence must
|
||||
remain different fields and must never be merged. The model should omit an
|
||||
occurrence when the transcript does not support choosing among same-name
|
||||
locations. If a returned selector does not resolve to exactly one supplied
|
||||
registry record, the extractor invalidates the complete operation rather than
|
||||
guessing.
|
||||
|
||||
### Registry reconciliation
|
||||
|
||||
The shared entity-reconciliation input replaces opaque candidate keys with
|
||||
contextual candidate descriptors. At minimum, a descriptor contains the
|
||||
candidate's display name and its canonically ordered source-reference ranges;
|
||||
the existing transcript windows remain available for semantic judgment.
|
||||
|
||||
Duplicate-group members and the canonical member in the private response use
|
||||
the same contextual descriptor shape. The shared reconciliation helper maps
|
||||
each descriptor back to exactly one internal candidate before assessing the
|
||||
proposal. Exact deterministic duplicates should already be removed before the
|
||||
LLM call; any remaining descriptor collision makes the affected candidate
|
||||
ineligible for model-assisted reconciliation rather than authorizing an
|
||||
arbitrary choice.
|
||||
|
||||
Existing safety behavior remains in force: groups must contain at least two
|
||||
supplied candidates, the canonical candidate must be a member, groups must not
|
||||
overlap, and domain-specific eligibility rules remain authoritative. Invalid,
|
||||
ambiguous, or unsafe groups are discarded through the existing bounded
|
||||
fallback and diagnostic behavior. The model never directly mutates the
|
||||
durable registry.
|
||||
|
||||
The shared private reconciliation schema and helper must remain domain-neutral
|
||||
within the D&D family. NPC-, item-, and location-specific duplicate policy
|
||||
continues to live in the owning normalizer.
|
||||
|
||||
## Prompt And Asset Changes
|
||||
|
||||
The following LLM-facing assets are in scope:
|
||||
|
||||
- the prompt instructions, registry input fragments, and private response
|
||||
schemas for NPC, item, and location occurrences;
|
||||
- the prompt manifests where input shape or selected fragments change;
|
||||
- the shared D&D entity-reconciliation fragment and private response schema;
|
||||
and
|
||||
- the NPC-, item-, and location-registry normalization prompt inputs that use
|
||||
the shared reconciliation contract.
|
||||
|
||||
Affected projections must exclude durable entity IDs rather than merely stop
|
||||
mentioning them in prose. Prompt instructions should describe the contextual
|
||||
selection rule once at the narrowest owning asset and must preserve the current
|
||||
distinction between registry grounding and transcript evidence.
|
||||
|
||||
Prompt ordering and cache controls should remain unchanged unless the new
|
||||
contextual input requires an intentional manifest change. Unrelated shared
|
||||
prompt bytes should not be edited. Prompt and schema fingerprints should
|
||||
invalidate only the operations whose selected assets or mapping semantics
|
||||
changed.
|
||||
|
||||
## Code And Validation Changes
|
||||
|
||||
The occurrence extractors need private response types and deterministic
|
||||
registry-resolution paths appropriate to their domain. Shared code is
|
||||
appropriate only for demonstrated mechanics that have identical semantics;
|
||||
NPC, item, and location ambiguity policies must not be forced behind a generic
|
||||
resolver merely to reduce line count.
|
||||
|
||||
Registry projections should expose explicit model-facing methods whose names
|
||||
describe whether they are names-only or contextual identity projections. The
|
||||
existing ID/name projections may remain only for deterministic consumers that
|
||||
genuinely require them; they must no longer be wired to an LLM input.
|
||||
|
||||
Mapping-policy and normalization-policy identifiers must be reviewed and
|
||||
advanced wherever their semantics change. Checkpoint fingerprints must cover
|
||||
the new projection content, private schema, prompt assets, and mapping policy,
|
||||
while continuing to exclude irrelevant internal implementation details.
|
||||
|
||||
Durable occurrence normalizers and registry validators remain defense in
|
||||
depth. They continue to validate exact ID/name pairs on artifacts entering
|
||||
through checkpoints, codecs, or other boundaries even though the LLM no longer
|
||||
produces the ID directly.
|
||||
|
||||
## Testing And Evaluation
|
||||
|
||||
Tests should protect the behavioral boundary rather than prompt prose or
|
||||
private helper structure. The completed work should demonstrate that:
|
||||
|
||||
- affected model-facing registry projections do not contain durable entity
|
||||
IDs;
|
||||
- private occurrence schemas reject opaque ID fields and accept the intended
|
||||
contextual shape;
|
||||
- valid contextual selections map to the exact canonical durable ID/name pair;
|
||||
- unknown, mismatched, and ambiguous selections fail without fuzzy matching,
|
||||
partial acceptance, or arbitrary reassignment;
|
||||
- same-name locations remain distinguishable through contextual evidence;
|
||||
- reconciliation preserves equal-name candidates, resolves valid contextual
|
||||
groups, and discards ambiguous or unsafe proposals;
|
||||
- registry evidence never becomes occurrence evidence;
|
||||
- durable codec, normalization, and validator behavior remains compatible; and
|
||||
- representative assembled D&D pipelines still prepare and execute with fake
|
||||
structured-LLM responses.
|
||||
|
||||
Do not add repository-wide prompt-prose snapshots, exact-message-count tests,
|
||||
or a change-detector test that merely scans for today's field names. Focused
|
||||
projection, schema, mapping, fallback, and integration tests are the stable
|
||||
owners of these risks. Model-quality evaluation with representative
|
||||
transcripts remains a manual development aid rather than an offline test gate.
|
||||
|
||||
## Documentation And Architectural Record
|
||||
|
||||
This policy is durable and applies to future modules, so it warrants
|
||||
`docs/adr/0012-resolve-opaque-entity-identifiers-deterministically.md`, which
|
||||
records:
|
||||
|
||||
- the semantic-proposal versus referential-integrity boundary;
|
||||
- why durable opaque IDs are excluded from model response contracts;
|
||||
- why contextual evidence coordinates remain permitted;
|
||||
- the narrowly scoped request-local-label exception;
|
||||
- alternatives including durable IDs, names-only matching, and short opaque
|
||||
handles; and
|
||||
- the consequences for private schemas, deterministic resolution, debugging,
|
||||
and ambiguous identities.
|
||||
|
||||
`docs/policy/architecture.md` states the general LLM boundary invariant and
|
||||
links to the ADR. `docs/internal/dnd.md` describes the concrete occurrence
|
||||
projections, contextual reconciliation selectors, resolution and failure
|
||||
behavior, and the continued separation of registry grounding from occurrence
|
||||
evidence. `docs/internal/llm.md` contains only a short clarification that
|
||||
caller-owned modules, not PromptKit or the transport adapter, resolve
|
||||
contextual model selections into application identities.
|
||||
|
||||
The NPC, item, and location occurrence and registry integration documents must
|
||||
continue to own their durable wire contracts, while removing current claims
|
||||
that the model-facing consumer projection contains `{id,name}` or that the raw
|
||||
LLM response supplies the durable ID. They should instead explain that
|
||||
Notarius resolves contextual model output and publishes the same exact durable
|
||||
ID/name pair. No public schema examples need to remove those IDs.
|
||||
|
||||
The generic LLM-assisted deduplication entry in `docs/roadmap/future.md` must be
|
||||
reconciled with this policy: stable IDs may exist inside deterministic state,
|
||||
but a future model-facing proposal should use contextual selectors or a
|
||||
documented request-local-label exception rather than durable IDs.
|
||||
|
||||
## Compatibility And Operational Effects
|
||||
|
||||
This work intentionally changes private prompt inputs, private structured
|
||||
responses, and mapping semantics. It will invalidate affected checkpoints
|
||||
through existing prompt, schema, projection, and policy fingerprints. No
|
||||
manual checkpoint migration is required.
|
||||
|
||||
Durable D&D artifacts and generated-reference compatibility remain unchanged.
|
||||
Operators do not receive new configuration fields or CLI controls. The feature
|
||||
does not change PromptKit, provider routing, profile selection, retries,
|
||||
concurrency, or public output placement.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
This work does not:
|
||||
|
||||
- remove canonical IDs from durable registries or occurrence artifacts;
|
||||
- change occurrence categories, evidence rules, or registry identity policy;
|
||||
- add fuzzy, probabilistic, or embedding-based entity resolution;
|
||||
- allow registry provenance to substitute for occurrence evidence;
|
||||
- introduce a general entity graph or cross-artifact identity framework;
|
||||
- redesign unrelated D&D prompts or their schemas;
|
||||
- implement the future generic deduplication normalizer; or
|
||||
- add provider-specific prompt behavior.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The target state is complete when:
|
||||
|
||||
- no maintained D&D prompt requires a model to reproduce a durable opaque
|
||||
entity ID;
|
||||
- current D&D reconciliation prompts no longer require opaque candidate keys;
|
||||
- NPC, item, and location occurrence LLM outputs are resolved
|
||||
deterministically into their unchanged durable artifacts;
|
||||
- same-name location and reconciliation cases remain safe and unambiguous;
|
||||
- invalid contextual selections preserve the existing extraction-failure or
|
||||
normalization-fallback semantics appropriate to their stage;
|
||||
- affected checkpoint identities change without altering public schema
|
||||
versions;
|
||||
- focused and repository-wide tests pass offline;
|
||||
- the ADR, architecture invariant, D&D internal guide, LLM internal guide,
|
||||
relevant integration contracts, and future roadmap accurately describe
|
||||
their canonical portions of the implemented policy; and
|
||||
- no unrelated code, prompt behavior, or public contract changes are included.
|
||||
@@ -24,28 +24,52 @@ not as committed release dates.
|
||||
|
||||
## Shared Normalization And Quality Work
|
||||
|
||||
### Generic LLM-Assisted Deduplication
|
||||
The implemented source-backed core and initial D&D registry adoption are
|
||||
described by [Module Internals](../internal/modules.md#semantic-reconciliation)
|
||||
and
|
||||
[D&D Module Internals](../internal/dnd.md#semantic-registry-reconciliation).
|
||||
The [Semantic Reconciliation Roadmap](semantic-reconciliation.md) retains the
|
||||
original feature scope; the sections below keep broader extensions deferred.
|
||||
|
||||
- Add a reusable normalizer that asks an LLM to identify duplicate sets in a
|
||||
list and propose one replacement element for each set.
|
||||
- Define the minimum domain-neutral input contract, initially an ordered list
|
||||
whose elements retain stable unique IDs as internal deterministic state.
|
||||
Model proposals use contextual descriptors, or a specifically justified
|
||||
request-local short label, rather than durable IDs. Artifact-kind
|
||||
registrations or adapters may expose that structure without moving domain
|
||||
rules into the generic package.
|
||||
- Keep mutation deterministic: parse and validate the model's duplicate groups,
|
||||
resolve every supplied descriptor or local label exactly, reject overlapping
|
||||
or malformed groups, prevent unrelated insertion or deletion, and apply only
|
||||
approved replacement operations in code.
|
||||
- Preserve provenance needed for audit and downstream validation, and emit
|
||||
warnings describing every collapsed group.
|
||||
- Evaluate batching and context-window limits before applying the normalizer to
|
||||
large artifact collections.
|
||||
### Large-Collection Semantic Reconciliation
|
||||
|
||||
The model may use its own domain knowledge to judge semantic duplication; the
|
||||
generic implementation is responsible only for the common proposal contract,
|
||||
safety checks, and deterministic application of accepted changes.
|
||||
- Evaluate deterministic candidate blocking only after representative registry
|
||||
inputs exceed the active roadmap's bounded single-request limits. Blocking
|
||||
should use cheap, explainable signals to form plausible comparison sets while
|
||||
preserving the possibility that a duplicate appears outside a lexical name
|
||||
match.
|
||||
- Define correctness for candidates that appear in more than one block,
|
||||
conflicting canonical selections, transitive identity across blocks, retry
|
||||
isolation, and deterministic final ordering before implementation.
|
||||
- Prefer a reconciliation graph or union plan with explicit conflict checks
|
||||
over arbitrary fixed-size slices. Never silently treat a batch boundary as
|
||||
evidence that two candidates are distinct.
|
||||
- Record per-request bounds, block provenance, model calls, discarded
|
||||
proposals, and final group derivation well enough to audit a collapse.
|
||||
|
||||
### Operator-Selected Semantic Policies
|
||||
|
||||
- Consider allowing an operator to select an approved semantic-policy prompt
|
||||
for a typed reconciliation module without replacing the shared protocol,
|
||||
response schema, or deterministic safety rules.
|
||||
- Define the trusted asset source, configuration syntax, compatibility checks,
|
||||
startup validation, provenance, prompt fingerprinting, checkpoint effects,
|
||||
and support boundary before exposing the option.
|
||||
- Prefer selection among registered, typed-policy-compatible prompt assets over
|
||||
arbitrary filesystem prompt paths. Do not add this flexibility until an
|
||||
operator workflow requires it; artifact-family-owned policy remains simpler
|
||||
and safer for the initial implementation.
|
||||
|
||||
### Broader Reconciliation Inputs And Module Selection
|
||||
|
||||
- Revisit alternate context providers when a concrete non-source-backed entity
|
||||
collection needs semantic reconciliation. Any extension must preserve the
|
||||
same request-local identity, deterministic proposal validation, provenance,
|
||||
and typed application guarantees.
|
||||
- Consider a selectable generic normalizer only if Notarius gains a real
|
||||
domain-neutral typed artifact contract that can safely support it. Do not
|
||||
weaken exact artifact registration or introduce reflection-based arbitrary
|
||||
JSON mutation merely to expose a universal module key.
|
||||
|
||||
### Validation And Review
|
||||
|
||||
@@ -102,6 +126,18 @@ checkpoint reuse, when an older artifact may be decoded or adapted, and when a
|
||||
producer or all dependents must be recomputed. Do not add a general migration
|
||||
framework until an actual contract change requires one.
|
||||
|
||||
### Artifact-family-oriented physical packaging
|
||||
|
||||
[ADR-0004](../adr/0004-package-modules-by-domain.md) currently groups production
|
||||
extensions by domain and then by pipeline stage. After artifact-family
|
||||
ownership terminology is established and more families span extraction,
|
||||
normalization, validation, codecs, references, and assets, reassess whether a
|
||||
feature-first physical layout would improve navigation and reduce scattered
|
||||
changes enough to justify a repository-wide package migration. Any change must
|
||||
address Go dependency cycles, registrar ownership, stable public module keys,
|
||||
and supersession of the affected ADR-0004 decision. Conceptual artifact-family
|
||||
ownership does not by itself require this move.
|
||||
|
||||
## Blue-Sky Platform And Operations
|
||||
|
||||
These ideas are intentionally less specified. Promote one into an earlier
|
||||
|
||||
@@ -1,612 +0,0 @@
|
||||
# Contextual Entity Grounding Implementation Plan
|
||||
|
||||
## Objective
|
||||
|
||||
Implement [Contextual Entity Grounding](contextual-entity-grounding.md) so D&D
|
||||
LLM prompts return evidence-grounded contextual selectors while Notarius owns
|
||||
canonical entity IDs and referential integrity. Preserve every durable D&D
|
||||
artifact contract and remove opaque IDs only from model-visible inputs and
|
||||
private model responses.
|
||||
|
||||
This plan is written for a gpt-5.6-terra coding agent. Implement the stages in
|
||||
numeric order. Each stage is intentionally scoped to one implementation prompt
|
||||
and must leave the repository buildable and its focused tests passing before
|
||||
the next stage begins.
|
||||
|
||||
## Plan-Wide Decisions
|
||||
|
||||
Apply these decisions throughout every stage:
|
||||
|
||||
- Read `docs/development.md`, all files under `docs/policy/`, the feature
|
||||
roadmap, and the focused implementation/tests named by the stage before
|
||||
editing.
|
||||
- Preserve the fixed pipeline, typed artifact boundaries, root `assets`
|
||||
content-only rule, module ownership, PromptKit boundary, and evidence rules.
|
||||
- Do not change the durable NPC-, item-, location-registry, or occurrence Go
|
||||
types, JSON schemas, schema IDs, schema versions, media types, reference-slot
|
||||
contracts, categories, or generated-reference compatibility.
|
||||
- Keep the affected prompt and private response-schema identities at `v1`.
|
||||
They are private pre-release transport contracts; their changed content
|
||||
hashes provide the required compatibility boundary.
|
||||
- Advance semantic policy identifiers exactly as directed in each stage. Do
|
||||
not bump unrelated policy identifiers.
|
||||
- A contextual name match uses the entity family's existing comparison policy,
|
||||
never fuzzy matching. A model selection must resolve to exactly one supplied
|
||||
record before a durable ID is attached.
|
||||
- An invalid NPC, item, or location selection invalidates the complete
|
||||
extraction operation. Do not silently drop one response record, accept a
|
||||
partial artifact, or defer a known mapping failure to a later validator.
|
||||
- Registry provenance remains grounding only. Only the current extraction
|
||||
chunk's `source_refs` become occurrence evidence.
|
||||
- Preserve prompt message order and cache controls unless a stage explicitly
|
||||
directs otherwise. Edit only the selected module or shared assets; do not
|
||||
rewrite unrelated shared prompt bytes.
|
||||
- Preserve internal opaque IDs where deterministic code needs them. The rule
|
||||
applies to material shown to the model or requested from it, not to maps,
|
||||
fingerprints, checkpoints, diagnostics, or durable artifacts.
|
||||
- Follow `docs/policy/testing.md`: test package-level behavior and meaningful
|
||||
failure modes, not prompt prose, exact message counts, private helper
|
||||
structure, or a repository-wide string-scanning change detector. All tests
|
||||
remain deterministic, offline, and credential-free.
|
||||
- Use `apply_patch` for edits, `gofmt` changed Go files, and preserve unrelated
|
||||
worktree changes.
|
||||
|
||||
## Final Private Selector Contracts
|
||||
|
||||
These shapes are implementation requirements, not public artifact schemas.
|
||||
|
||||
### NPC occurrence response
|
||||
|
||||
Each response record contains exactly the required fields `name`, `kind`, and
|
||||
`source_refs`. It does not contain `npc_id`. Notarius resolves `name` through
|
||||
the normalized NPC registry and writes the matched registry record's `ID` and
|
||||
canonical `Name` into the durable occurrence.
|
||||
|
||||
### Item occurrence response
|
||||
|
||||
Each response record contains the existing required `name`, `kind`,
|
||||
`quantity`, `from`, `to`, and `source_refs` fields. It does not contain
|
||||
`item_id`. Retain the current nullable representation and kind-specific
|
||||
semantics. Notarius resolves `name` through the normalized item registry and
|
||||
adds the matched `ID` and canonical `Name`.
|
||||
|
||||
### Location occurrence response
|
||||
|
||||
Each response record contains exactly the required fields `name`,
|
||||
`registry_refs`, `kind`, and `source_refs`. `registry_refs` is always an array
|
||||
of strict objects containing required integer `start_unit_id` and
|
||||
`end_unit_id`; it may be empty.
|
||||
|
||||
- When `name` has one comparison-identity match in the supplied registry,
|
||||
`registry_refs` must be empty and name resolution selects that record.
|
||||
- When multiple registry records share the comparison identity,
|
||||
`registry_refs` must equal one record's complete canonically ordered source
|
||||
ranges with `source_id` removed.
|
||||
- The model-facing registry projection uses the same `name` plus
|
||||
`registry_refs` selector and adds a required `context` array. Unique-name
|
||||
records project empty `registry_refs` and `context` arrays. The private
|
||||
response does not reproduce `context`.
|
||||
- Same-name records receive bounded identity context consisting of the ordered
|
||||
source units covered by their registry ranges. Each context element is a
|
||||
strict object with exactly the required fields `unit_id` (integer) and
|
||||
`text` (string); do not expose the durable location ID, source ID, digest,
|
||||
or a replacement token.
|
||||
- Building same-name grounding validates that every registry range belongs to
|
||||
and resolves against the current source. If two records still produce the
|
||||
same contextual selector, grounding construction fails before the LLM call.
|
||||
- `registry_refs` never flow into the durable occurrence's `source_refs`.
|
||||
|
||||
### Entity-reconciliation response
|
||||
|
||||
The shared response remains an object with required `duplicate_groups`.
|
||||
Every group has required `members` and `canonical`. A member and the canonical
|
||||
selection are strict contextual objects containing:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Mira Thorn",
|
||||
"source_refs": [
|
||||
{"start_unit_id": 12, "end_unit_id": 12}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The candidate prompt input uses the same descriptor and contains no `key`.
|
||||
`source_refs` is required and non-empty for every eligible candidate. The
|
||||
shared helper may retain its existing `candidate-000001`-style keys strictly
|
||||
inside Go state to preserve input-position mapping; those keys must never be
|
||||
serialized into prompt input or accepted in the private response.
|
||||
|
||||
If two candidates produce an identical contextual descriptor, neither is
|
||||
eligible for model-assisted reconciliation because the model cannot identify
|
||||
them independently. Otherwise the helper converts returned descriptors to its
|
||||
internal candidate keys before applying all existing unknown-member,
|
||||
ineligible-member, duplicate-member, canonical-membership, overlap, retry, and
|
||||
fallback rules.
|
||||
|
||||
## Stage 1: Convert NPC Occurrences To Name-Based Resolution
|
||||
|
||||
### Goal
|
||||
|
||||
Remove durable NPC IDs from the NPC-occurrence prompt and private response,
|
||||
then resolve the model's contextual name deterministically without weakening
|
||||
checkpoint identity or downstream validation.
|
||||
|
||||
### Work
|
||||
|
||||
1. Inspect:
|
||||
- `assets/dnd/npc-occurrences/`;
|
||||
- `internal/modules/dnd/extract/npcoccurrences/`;
|
||||
- `internal/modules/dnd/npcs/registry/`;
|
||||
- NPC-occurrence normalizer and validator checkpoint fingerprints; and
|
||||
- their focused tests.
|
||||
2. Change `dnd_npc_occurrences_llm.v1.json` so every occurrence requires only
|
||||
`name`, `kind`, and `source_refs`, continues to reject unknown fields, and
|
||||
no longer declares `npc_id`.
|
||||
3. Revise the NPC-occurrence instructions to require a supplied canonical NPC
|
||||
name and current-chunk evidence, with no instruction to copy or invent an
|
||||
ID. Continue using the existing shared names-only NPC registry fragment and
|
||||
preserve manifest order/cache controls.
|
||||
4. Remove `NPCID` from the private `occurrenceResponse`. After canonicalizing
|
||||
response evidence, resolve every response name with the existing
|
||||
`npcregistry.Registry.Lookup` comparison-key lookup. On the first unknown
|
||||
or non-unique selection, return an extractor-scoped mapping error and no
|
||||
value. For a match, construct the durable occurrence with the registry
|
||||
record's exact `ID` and canonical `Name`.
|
||||
5. Change `mappingPolicy` to
|
||||
`dnd.npc_occurrences.extract_mapping.v3`.
|
||||
6. Stop passing `IdentityPromptInput()` to the LLM; use the existing
|
||||
names-only `PromptInput()`.
|
||||
7. Replace the misleading exported model-input API used only for identity
|
||||
fingerprints: retain the unexported ordered `{id,name}` projection and its
|
||||
digest, expose that value as `IdentityDigest() string`, remove
|
||||
`IdentityPromptInput()`, and update NPC-occurrence extractor, normalizer,
|
||||
invariant-validator, and registry-validator fingerprints to use
|
||||
`IdentityDigest()`. The digest must still distinguish ID/name identity from
|
||||
the names-only prompt projection.
|
||||
8. Rewrite existing focused tests around observable behavior: rendered NPC
|
||||
registry input is names-only; the private schema rejects `npc_id`; valid
|
||||
names acquire the registry ID; comparison-equivalent names canonicalize;
|
||||
unknown names fail the whole extraction; registry identity fingerprints
|
||||
remain distinct and defensive; empty registries accept only empty model
|
||||
results. Remove tests whose only purpose was requiring the model to return
|
||||
exact ID/name pairs.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- No NPC-occurrence LLM input or private response contains a durable NPC ID.
|
||||
- Durable NPC occurrences still contain the exact registry ID/name pair.
|
||||
- Mapping failures remain extractor failures eligible for the configured
|
||||
pipeline retry behavior.
|
||||
- Deterministic consumers still fingerprint the ordered registry identity,
|
||||
while spells, combat turns, enemy events, and NPC occurrences share the
|
||||
names-only model projection.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
go fmt ./internal/modules/dnd/npcs/registry ./internal/modules/dnd/extract/npcoccurrences ./internal/modules/dnd/normalize/npcoccurrences ./internal/modules/dnd/validate/npcoccurrences/...
|
||||
go test ./internal/modules/dnd/npcs/registry ./internal/modules/dnd/extract/npcoccurrences ./internal/modules/dnd/normalize/npcoccurrences ./internal/modules/dnd/validate/npcoccurrences/...
|
||||
```
|
||||
|
||||
This stage is suitable for one gpt-5.6-terra prompt.
|
||||
|
||||
## Stage 2: Convert Item Occurrences To Name-Based Resolution
|
||||
|
||||
### Goal
|
||||
|
||||
Give item occurrences the same contextual-name/deterministic-ID boundary while
|
||||
preserving item-specific nullable fields and kind rules.
|
||||
|
||||
### Work
|
||||
|
||||
1. Inspect `assets/dnd/item-occurrences/`, the item occurrence extractor, the
|
||||
item registry, the item occurrence normalizer and registry validator, and
|
||||
their focused tests.
|
||||
2. Change `dnd_item_occurrences_llm.v1.json` to remove `item_id` from required
|
||||
fields and properties. Preserve required `name`, `kind`, `quantity`, `from`,
|
||||
`to`, and `source_refs`, all current enums/nullability, and strict unknown
|
||||
field rejection.
|
||||
3. Rewrite the item registry fragment and module instructions to require the
|
||||
supplied canonical name and current-chunk evidence without mentioning an
|
||||
ID. Preserve prompt order and cache controls.
|
||||
4. Change the item registry's model projection from ordered `{id,name}` pairs
|
||||
to ordered names-only objects, add a comparison-key index, and expose a
|
||||
defensive `Lookup(name) (dnd.Item, bool)` analogous to the NPC registry.
|
||||
Retain exact `LookupID` for durable normalizers and validators. Because item
|
||||
IDs are derived from the item comparison identity, the names-only
|
||||
`ProjectionDigest` remains sufficient for model input and existing
|
||||
checkpoint consumers.
|
||||
5. Remove `ItemID` from the private response. During response canonicalization,
|
||||
resolve every contextual name, replace it with the registry record's
|
||||
canonical name, and attach its durable ID when constructing the final
|
||||
`dnd.ItemOccurrence`. Unknown selections fail the complete extraction; do
|
||||
not alter evidence or nullable-field validation ownership.
|
||||
6. Change `mappingPolicy` to
|
||||
`dnd.item_occurrences.extract_mapping.v2`.
|
||||
7. Update focused tests to cover names-only projection, defensive comparison
|
||||
lookup, schema rejection of `item_id`, deterministic durable mapping,
|
||||
unknown-name failure after an otherwise valid record, empty registry/result
|
||||
behavior, and preservation of nullable/kind-specific fields.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Model-visible item registry and response content contain no item hash.
|
||||
- Every accepted durable occurrence has the matched registry ID and canonical
|
||||
name.
|
||||
- Invalid selection remains all-or-nothing, and existing normalizer/validator
|
||||
defense in depth remains unchanged.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
go fmt ./internal/modules/dnd/items/registry ./internal/modules/dnd/extract/itemoccurrences
|
||||
go test ./internal/modules/dnd/items/registry ./internal/modules/dnd/extract/itemoccurrences ./internal/modules/dnd/normalize/itemoccurrences ./internal/modules/dnd/validate/itemoccurrences/...
|
||||
```
|
||||
|
||||
This stage is suitable for one gpt-5.6-terra prompt.
|
||||
|
||||
## Stage 3: Add Contextual Location Grounding
|
||||
|
||||
### Goal
|
||||
|
||||
Replace the location registry's ID/name prompt projection with an immutable,
|
||||
source-aware grounding object that can represent same-name locations safely.
|
||||
Introduce the new path alongside the old occurrence input so this stage remains
|
||||
buildable; Stage 4 performs the atomic extractor cutover and removes the old
|
||||
path.
|
||||
|
||||
### Work
|
||||
|
||||
1. Inspect the location registry, location identity and source-reference
|
||||
helpers, the generic source document index, occurrence checkpoint consumers,
|
||||
and their focused tests.
|
||||
2. In `internal/modules/dnd/locations/registry`, define the private-model
|
||||
types needed by both grounding and the location occurrence extractor:
|
||||
- a returned selector with exactly `name` and `registry_refs`;
|
||||
- a registry projection entry with exactly `name`, `registry_refs`, and
|
||||
`context`;
|
||||
- a source-free range with exactly `start_unit_id` and `end_unit_id`; and
|
||||
- a context unit with exactly `unit_id` and `text`.
|
||||
All fields are required in their private JSON shapes, and constructors and
|
||||
accessors must make defensive copies.
|
||||
3. Add an operation-scoped immutable grounding type constructed from a resolved
|
||||
registry and the current `*source.SourceDocument`. Its API must provide:
|
||||
- a cloned `contracts.LLMInputMaterial` for the `location_registry` slot;
|
||||
- deterministic resolution of a returned selector to one cloned
|
||||
`dnd.Location`.
|
||||
The prompt material's existing `Digest` field owns the digest of the exact
|
||||
model projection; do not expose a second grounding-specific digest API.
|
||||
4. Construct the projection in registry order. Group entries by the existing
|
||||
location comparison key:
|
||||
- every projection entry has exactly the required fields `name`,
|
||||
`registry_refs`, and `context`;
|
||||
- comparison-unique entries use empty `registry_refs` and `context` arrays;
|
||||
- every same-name entry uses its complete canonical source ranges stripped
|
||||
of `source_id` and includes ordered context units covered by those ranges;
|
||||
- each context unit contains exactly required integer `unit_id` and string
|
||||
`text` fields, and units are deduplicated in source order; and
|
||||
- same-name ranges must have `SourceID == doc.ID` and pass
|
||||
`source.DocumentIndex.ValidateRef`.
|
||||
5. Fail grounding construction with a bounded, content-safe error if the
|
||||
source is nil, a same-name range is invalid or belongs to another source,
|
||||
a comparison key is empty, or two records produce the same selector. Do not
|
||||
expose transcript text in the error.
|
||||
6. Resolution uses the existing comparison key. A unique-name selector is
|
||||
accepted only with empty `registry_refs`; a same-name selector is accepted
|
||||
only on an exact canonical range match. Reject unknown names, a non-empty
|
||||
range list for a unique name, an empty/partial/reordered range list for an
|
||||
ambiguous name, or any selector not present in the grounding.
|
||||
7. Separate deterministic identity fingerprinting from LLM material. Add
|
||||
`IdentityDigest()` over the registry's ordered `{id,name}` identity
|
||||
projection, and update the location normalizer and registry-validator
|
||||
checkpoint consumers to use it. The new operation grounding carries the
|
||||
model projection digest in its `LLMInputMaterial`. Retain the old ID-bearing
|
||||
prompt accessor only as a documented transitional dependency of the
|
||||
still-unchanged location occurrence extractor; do not add new callers.
|
||||
8. Add focused tests for unique names, same-name context and selectors,
|
||||
canonical range order, deterministic projection/digest, defensive copies,
|
||||
exact selector resolution, nil/foreign/invalid references, selector
|
||||
collisions, empty registries, and identity fingerprint stability. Do not
|
||||
assert large rendered prompt strings; decode the JSON projection and assert
|
||||
its semantic shape.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- The location package can build and resolve contextual selectors without
|
||||
exposing `location_id`, `source_id`, digests, or replacement labels.
|
||||
- Same-name locations remain distinct and receive meaningful bounded context.
|
||||
- Deterministic checkpoint consumers retain an ID-sensitive fingerprint.
|
||||
- Only the existing occurrence extractor remains wired to the legacy
|
||||
ID-bearing prompt path until Stage 4; the repository compiles and tests pass.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
go fmt ./internal/modules/dnd/locations/registry ./internal/modules/dnd/normalize/locationoccurrences ./internal/modules/dnd/validate/locationoccurrences/...
|
||||
go test ./internal/modules/dnd/locations/registry ./internal/modules/dnd/normalize/locationoccurrences ./internal/modules/dnd/validate/locationoccurrences/...
|
||||
```
|
||||
|
||||
This stage is suitable for one gpt-5.6-terra prompt.
|
||||
|
||||
## Stage 4: Convert Location Occurrences To Contextual Resolution
|
||||
|
||||
### Goal
|
||||
|
||||
Wire the Stage 3 grounding object into location occurrence extraction and
|
||||
remove durable location IDs from the prompt and private response.
|
||||
|
||||
### Work
|
||||
|
||||
1. Inspect `assets/dnd/location-occurrences/`, the location occurrence model,
|
||||
schema loader, extractor, canonicalization, prompt tests, and Stage 3
|
||||
grounding tests.
|
||||
2. Change `dnd_location_occurrences_llm.v1.json` so each occurrence requires
|
||||
exactly `name`, `registry_refs`, `kind`, and `source_refs`; remove
|
||||
`location_id`. Keep all four occurrence kinds. Make `registry_refs` a
|
||||
required array, including an empty array, of strict required positive
|
||||
integer ranges. Keep occurrence `source_refs` separate and unchanged.
|
||||
3. Rewrite `location-registry.md` and module instructions to explain the two
|
||||
selector cases, require exact supplied contextual selectors, prohibit
|
||||
invented locations, and state that registry ranges/context are identity
|
||||
grounding rather than occurrence evidence. Preserve manifest order and
|
||||
cache controls.
|
||||
4. Change the private response type to `Name`, `RegistryRefs`, `Kind`, and
|
||||
`SourceRefs`. Do not reuse `source.SourceRef` for the source-free registry
|
||||
range type.
|
||||
5. In `Extract`, construct operation grounding from the resolved registry and
|
||||
`req.Source` before calling the LLM, put its projection in the
|
||||
`location_registry` input, and resolve every returned selector after
|
||||
completion. Attach the selected registry record's exact ID and canonical
|
||||
name to the durable occurrence while retaining only the response's
|
||||
current-source `source_refs` as evidence.
|
||||
6. Fail the whole extraction on grounding-construction failure or the first
|
||||
unknown, malformed, mismatched, or ambiguous selector. This replaces the
|
||||
current behavior that can preserve unknown ID/name pairs for later
|
||||
validators. Keep later normalizer and validator checks as defense in depth
|
||||
for artifacts entering other boundaries.
|
||||
7. Change `mappingPolicy` to
|
||||
`dnd.location_occurrences.extract_mapping.v2`.
|
||||
8. Remove the legacy ID-bearing registry `PromptInput` and its model-projection
|
||||
digest once the extractor uses operation grounding. Keep the occurrence's
|
||||
static module fingerprint based on `IdentityDigest()`, prompt/schema
|
||||
fingerprints, and mapping policy. The operation-scoped projection is already
|
||||
covered by source/chunk identity and configured or generated reference
|
||||
dependencies, while its `LLMInputMaterial.Digest` identifies the exact model
|
||||
input; do not add a second digest API or operation-aware static fingerprint.
|
||||
9. Update focused schema, prompt, extractor, canonicalization, checkpoint, and
|
||||
generated-reference tests. Cover unique-name empty selectors, successful
|
||||
same-name selection, failure for an unsupported ambiguous mention,
|
||||
partial/reordered ranges, no registry-to-occurrence evidence leakage,
|
||||
all-or-nothing failure, empty registry/result behavior, and unchanged
|
||||
durable ordering/deduplication.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- The location prompt and private response contain no durable location ID.
|
||||
- Unique and same-name records resolve according to the final selector
|
||||
contract.
|
||||
- Accepted durable output is unchanged in shape and still contains an exact
|
||||
location ID/name pair.
|
||||
- Registry context cannot become durable occurrence evidence.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
go fmt ./internal/modules/dnd/extract/locationoccurrences ./internal/modules/dnd/locations/registry
|
||||
go test ./internal/modules/dnd/locations/registry ./internal/modules/dnd/extract/locationoccurrences ./internal/modules/dnd/normalize/locationoccurrences ./internal/modules/dnd/validate/locationoccurrences/...
|
||||
```
|
||||
|
||||
This stage is suitable for one gpt-5.6-terra prompt.
|
||||
|
||||
## Stage 5: Replace Reconciliation Keys With Contextual Descriptors
|
||||
|
||||
### Goal
|
||||
|
||||
Change the shared NPC/item/location registry-normalization proposal contract so
|
||||
opaque candidate keys remain internal and the model sees and returns only
|
||||
names plus evidence coordinates.
|
||||
|
||||
### Work
|
||||
|
||||
1. Inspect:
|
||||
- `internal/modules/dnd/shared/entityreconcile/`;
|
||||
- `assets/dnd/shared/prompts/common-dnd-entity-reconciliation.md`;
|
||||
- `assets/dnd/entity-reconciliation/schemas/`;
|
||||
- all three registry normalization manifests and prompt tests; and
|
||||
- the NPC, item, and location registry normalizers and reconciliation tests.
|
||||
2. Introduce one exported, defensively copied contextual selector type in
|
||||
`entityreconcile` with JSON `name` and `source_refs`, plus a strict
|
||||
source-free range type. Use it for candidate input views and for
|
||||
`DuplicateGroup.Members` and `.Canonical`.
|
||||
3. Keep deterministic candidate keys only inside `Materials`. During
|
||||
`BuildContext`, validate and canonicalize candidate references as today,
|
||||
serialize candidate views without `key`, derive a stable internal lookup
|
||||
from canonical selector JSON to the corresponding internal candidate key,
|
||||
and detect descriptor collisions before eligibility is established.
|
||||
Colliding candidates must not appear in the prompt input or become
|
||||
eligible; their records remain in deterministic normalization output.
|
||||
4. Update `Materials.Assess` to resolve every returned selector through that
|
||||
internal lookup before running the existing group assessment. Preserve
|
||||
existing issue categories where their meaning still applies. Treat an
|
||||
unknown or collided descriptor as an unknown/ineligible selection, discard
|
||||
only the affected group, and retain existing overlap handling. `SafeGroup`
|
||||
may continue returning internal candidate keys so the three domain
|
||||
normalizers retain their position mapping; those keys are not model-facing.
|
||||
5. Rewrite `dnd_entity_reconcile_llm.v1.json` so members and canonical are
|
||||
strict selector objects. Require non-empty `name` structurally where the
|
||||
current schemas do so, require `source_refs`, and make each range strict
|
||||
with required positive integer endpoints. Preserve `duplicate_groups` and
|
||||
the existing semantic assessment of minimum group size, membership,
|
||||
duplicates, eligibility, and overlap rather than moving every semantic
|
||||
failure into JSON Schema.
|
||||
6. Rewrite the shared reconciliation fragment to tell the model to return
|
||||
supplied contextual descriptors and never invent names or ranges. Remove
|
||||
every instruction about opaque keys. Preserve all three manifests' message
|
||||
ordering and cache controls.
|
||||
7. Change registry normalization policy identifiers to:
|
||||
- `dnd.npc_registry.normalize.v4`;
|
||||
- `dnd.item_registry.normalize.v2`; and
|
||||
- `dnd.location_registry.normalize.v2`.
|
||||
8. Update shared and domain tests to cover candidate JSON without keys,
|
||||
contextual proposal decoding, valid selector-to-internal-key mapping,
|
||||
equal names with different evidence, descriptor collision exclusion,
|
||||
unknown/partial/reordered descriptors, overlapping groups, canonical
|
||||
membership, invalid structured-output fallback, currency safety, and
|
||||
preservation of every non-applied deterministic candidate. Update prompt
|
||||
asset fixtures to the new selector schema; do not snapshot prompt prose.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- No registry normalization prompt input or private response contains a
|
||||
`candidate-*` key.
|
||||
- Internal keys remain inaccessible to the model but may still support safe
|
||||
deterministic position mapping.
|
||||
- All existing normalizer safety, retry, fallback, warning, currency, and
|
||||
same-name-location policies remain intact.
|
||||
- Identical contextual descriptors cannot be arbitrarily reconciled.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
go fmt ./internal/modules/dnd/shared/entityreconcile ./internal/modules/dnd/normalize/npcregistry ./internal/modules/dnd/normalize/itemregistry ./internal/modules/dnd/normalize/locationregistry
|
||||
go test ./internal/modules/dnd/shared/entityreconcile ./internal/modules/dnd/normalize/npcregistry ./internal/modules/dnd/normalize/itemregistry ./internal/modules/dnd/normalize/locationregistry
|
||||
```
|
||||
|
||||
This is the largest stage, but it is one cohesive shared-contract migration
|
||||
and is suitable for one gpt-5.6-terra prompt when implemented exactly within
|
||||
the listed packages. Do not combine it with occurrence or documentation work.
|
||||
|
||||
## Stage 6: Record The Decision And Update Canonical Documentation
|
||||
|
||||
### Goal
|
||||
|
||||
Document the implemented policy in its durable architectural, internal, and
|
||||
integration homes without duplicating volatile details or presenting roadmap
|
||||
work as current behavior prematurely.
|
||||
|
||||
### Work
|
||||
|
||||
1. Re-read `docs/policy/documentation.md`, ADR-0003, ADR-0009, ADR-0011,
|
||||
`docs/internal/dnd.md`, `docs/internal/llm.md`, and the six affected registry
|
||||
and occurrence integration documents. Verify the code before describing it.
|
||||
2. Add
|
||||
`docs/adr/0012-resolve-opaque-entity-identifiers-deterministically.md` in
|
||||
the repository's Nygard ADR format with status `Accepted` and the actual
|
||||
implementation date. Record the model-semantic/deterministic-identity
|
||||
boundary, source-coordinate allowance, request-local-label exception,
|
||||
alternatives, ambiguity behavior, and consequences. Link ADR-0003 and
|
||||
ADR-0009 rather than repeating their complete decisions.
|
||||
3. Add a concise normative invariant under the LLM boundary in
|
||||
`docs/policy/architecture.md`: callers use contextual model selections and
|
||||
attach opaque application identities deterministically when possible. Link
|
||||
ADR-0012 for rationale.
|
||||
4. Update `docs/internal/dnd.md` to replace exact model-facing `{id,name}`
|
||||
claims with the implemented NPC/item names-only and location contextual
|
||||
selector behavior. Document reconciliation descriptors, internal-only keys,
|
||||
all-or-nothing occurrence mapping failures, normalization fallback, and the
|
||||
separation between registry and occurrence evidence. Do not duplicate the
|
||||
private JSON schemas.
|
||||
5. Add only a short ownership clarification to `docs/internal/llm.md`: the
|
||||
calling module resolves contextual selections; PromptKit and its adapter do
|
||||
not own entity identity.
|
||||
6. Update these durable integration contracts while preserving their public
|
||||
ID-bearing wire examples and schema statements:
|
||||
- `docs/integrations/dnd-npc-registry-artifacts.md`;
|
||||
- `docs/integrations/dnd-npc-occurrence-artifacts.md`;
|
||||
- `docs/integrations/dnd-item-registry-artifacts.md`;
|
||||
- `docs/integrations/dnd-item-occurrence-artifacts.md`;
|
||||
- `docs/integrations/dnd-location-registry-artifacts.md`; and
|
||||
- `docs/integrations/dnd-location-occurrence-artifacts.md`.
|
||||
Remove claims that LLM consumers receive `{id,name}` or that raw model
|
||||
output supplies an ID. State that Notarius maps contextual output into the
|
||||
unchanged exact durable pair.
|
||||
7. Revise the generic LLM-assisted deduplication entry in
|
||||
`docs/roadmap/future.md`: stable unique IDs remain internal deterministic
|
||||
state, while a future model proposal uses contextual descriptors or a
|
||||
specifically justified request-local short label.
|
||||
8. Do not change README, CLI, configuration, operations, examples, or public
|
||||
schema files; this feature has no user-selectable surface or public wire
|
||||
change.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- ADR-0012 owns rationale; architecture owns the normative boundary; internal
|
||||
docs own mechanics; integration docs own unchanged durable contracts; and
|
||||
the future roadmap no longer proposes durable IDs as the default model
|
||||
selector.
|
||||
- No current-behavior document claims that a model copies hash-based entity
|
||||
IDs or opaque reconciliation keys.
|
||||
- Documentation does not duplicate private schemas or implementation history.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
git diff --check
|
||||
rg -n '\{id,name\}|ID/name grounding|Candidate keys are opaque|candidate-[0-9]' docs assets/dnd
|
||||
```
|
||||
|
||||
Review every search result semantically; durable wire-contract ID/name
|
||||
requirements and internal test fixtures are not automatically errors.
|
||||
|
||||
This stage is suitable for one gpt-5.6-terra prompt.
|
||||
|
||||
## Stage 7: Integration Audit And Final Verification
|
||||
|
||||
### Goal
|
||||
|
||||
Verify the assembled D&D family, remove obsolete identity-copy paths, and
|
||||
finish with a clean, policy-compliant implementation.
|
||||
|
||||
### Work
|
||||
|
||||
1. Audit every maintained D&D prompt manifest, selected fragment, private
|
||||
schema, and constructed prompt projection. Confirm that no model is asked to
|
||||
reproduce `npc:sha256:...`, `item:sha256:...`,
|
||||
`location:sha256:...`, `candidate-*`, a UUID, a digest, or another opaque
|
||||
entity handle. Do not confuse runtime metadata or durable output contracts
|
||||
with model-visible material.
|
||||
2. Trace all former APIs and fields, including `IdentityPromptInput`,
|
||||
ID-bearing item/location prompt projections, private `NPCID`/`ItemID`/
|
||||
`LocationID` response fields, and model-visible candidate keys. Remove dead
|
||||
code, obsolete comments, stale test names, and unused assets. Retain
|
||||
identity-only digests and exact durable lookup APIs used by deterministic
|
||||
consumers.
|
||||
3. Review prompt fingerprint registration and checkpoint fingerprints. Confirm
|
||||
that each affected prompt/schema/policy/projection change invalidates the
|
||||
relevant operation and that unrelated D&D lanes retain their existing
|
||||
fingerprints.
|
||||
4. Run representative production registration and multi-step pipeline tests
|
||||
using existing fakes. Update only tests whose stable behavior changed.
|
||||
Confirm generated NPC/item/location registry handoffs still prepare and
|
||||
that final durable occurrences encode and validate under their existing
|
||||
`v1` codecs.
|
||||
5. Run formatting, focused suites, full tests, vet, build, and documentation
|
||||
whitespace checks. Fix only failures caused by this feature. Report any
|
||||
unrelated pre-existing failure without broadening scope.
|
||||
6. Review the feature roadmap acceptance criteria one by one. Do not delete
|
||||
`contextual-entity-grounding.md` or this implementation plan in this stage;
|
||||
roadmap retirement is a separate maintainer action after review.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- All feature-roadmap acceptance criteria are met.
|
||||
- The repository contains no obsolete model-facing opaque-identity path.
|
||||
- Public artifacts and generated handoffs remain compatible.
|
||||
- Tests are focused on behavior rather than prose or implementation shape.
|
||||
- The worktree contains only intentional feature and documentation changes.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
go fmt ./internal/modules/dnd/...
|
||||
go test ./internal/modules/dnd/...
|
||||
go test ./internal/modules/integration/...
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./cmd/notarius
|
||||
git diff --check
|
||||
git status --short
|
||||
```
|
||||
|
||||
This stage is suitable for one gpt-5.6-terra prompt.
|
||||
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})
|
||||
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
|
||||
if err != nil {
|
||||
@@ -161,10 +161,8 @@ func TestAssembledSpellPipelineRejectsUnknownSpellWithoutPromotingAttemptWarning
|
||||
if !reflect.DeepEqual(rejectedFile.Rejected, output.Rejected) {
|
||||
t.Fatalf("rejected file = %#v, run rejections = %#v, want durable rejection diagnostic", rejectedFile.Rejected, output.Rejected)
|
||||
}
|
||||
for _, warning := range output.Warnings {
|
||||
if warning.ReasonCode == spellnormalize.ReasonCodeSpellNameUnresolved {
|
||||
t.Fatalf("warnings = %#v, want rejected-attempt warning to remain non-durable", output.Warnings)
|
||||
}
|
||||
if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != spellnormalize.ReasonCodeSpellNameUnresolved || output.Warnings[0].Scope != "spell_casts[0]" {
|
||||
t.Fatalf("warnings = %#v, want terminal normalize catalog warning", output.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -189,10 +189,18 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
|
||||
}
|
||||
|
||||
evidence := readProductionJSON[evidencecontext.Document](t, filepath.Join(runRoot, "evidence-context.json"))
|
||||
for _, laneID := range []string{"enemy-events", "npc-registry", "npc-occurrences", "item-registry", "item-occurrences", "location-registry", "location-occurrences"} {
|
||||
if !containsString(evidence.SelectedLanes, laneID) || !evidenceHasLane(evidence, laneID) {
|
||||
t.Fatalf("evidence context = %#v, want direct %s evidence", evidence, laneID)
|
||||
if len(evidence) == 0 {
|
||||
t.Fatalf("evidence context = %#v, want selected source-unit evidence", evidence)
|
||||
}
|
||||
seenEvidenceUnits := make(map[int]struct{}, len(evidence))
|
||||
for _, unit := range evidence {
|
||||
if unit.Ref.SourceID != "session-ravenfall" || unit.Ref.StartUnitID != unit.ID || unit.Ref.EndUnitID != unit.ID {
|
||||
t.Fatalf("evidence unit = %#v, want unchanged source-unit self-reference", unit)
|
||||
}
|
||||
if _, exists := seenEvidenceUnits[unit.ID]; exists {
|
||||
t.Fatalf("evidence context = %#v, want each source unit once", evidence)
|
||||
}
|
||||
seenEvidenceUnits[unit.ID] = struct{}{}
|
||||
}
|
||||
|
||||
requests := client.requestsFor(enemyevents.PromptID)
|
||||
@@ -330,7 +338,7 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
|
||||
if len(registry.Items) != 1 || registry.Items[0].Name != "Moonblade" {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated item registry has %d items, want 1", len(registry.Items))
|
||||
}
|
||||
content = []byte(`{"occurrences":[{"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":5,"end_segment":5}]}]}`)
|
||||
content = []byte(`{"occurrences":[{"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_unit_id":5,"end_unit_id":5}]}]}`)
|
||||
}
|
||||
case combat.PromptID:
|
||||
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`)
|
||||
@@ -415,17 +423,6 @@ func containsString(values []string, want string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func evidenceHasLane(value evidencecontext.Document, laneID string) bool {
|
||||
for _, context := range value.Contexts {
|
||||
for _, reference := range context.EvidenceRefs {
|
||||
if reference.LaneID == laneID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func generatedReferenceBinding(bindings []pipeline.ReferenceBinding, slotName string) (pipeline.ReferenceBinding, bool) {
|
||||
for _, binding := range bindings {
|
||||
if binding.SlotName == slotName && binding.Artifact != nil {
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
||||
@@ -311,6 +312,47 @@ func TestProductionPromptAssetsPrepareWithoutProviderCredentials(t *testing.T) {
|
||||
if _, err := pipeline.Prepare(effective.ResolvedPipeline, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil {
|
||||
t.Fatalf("prepare production scene and spell modules: %v", err)
|
||||
}
|
||||
|
||||
schemaFS, err := components.assets.SchemaFS()
|
||||
if err != nil {
|
||||
t.Fatalf("production schema assets: %v", err)
|
||||
}
|
||||
if _, err := fs.ReadFile(schemaFS, filepath.Base(semanticreconcile.SchemaAssetPath)); err != nil {
|
||||
t.Fatalf("generic reconciliation schema asset: %v", err)
|
||||
}
|
||||
options, err := components.assets.PromptKitOptions()
|
||||
if err != nil {
|
||||
t.Fatalf("production PromptKit options: %v", err)
|
||||
}
|
||||
options = append(options, promptkit.WithProfiles(promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
|
||||
ID: "assembled-prompt-test", Endpoint: "http://127.0.0.1:1/v1", Model: "test",
|
||||
})))
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{}, options...)
|
||||
if err != nil {
|
||||
t.Fatalf("production prompt engine: %v", err)
|
||||
}
|
||||
inputs := map[string]promptkit.ArtifactRef{
|
||||
"candidates": promptkit.Inline(`{"candidates":[{"candidate_id":1,"label":"Alias","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
|
||||
"transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`),
|
||||
}
|
||||
for _, prompt := range []struct {
|
||||
id string
|
||||
version string
|
||||
}{
|
||||
{id: npcnormalize.PromptID, version: npcnormalize.PromptVersion},
|
||||
{id: itemregistrynormalize.PromptID, version: itemregistrynormalize.PromptVersion},
|
||||
{id: locationnormalize.PromptID, version: locationnormalize.PromptVersion},
|
||||
} {
|
||||
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||
PromptID: prompt.id, PromptVersion: prompt.version, ProfileID: "assembled-prompt-test", Inputs: inputs,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare production prompt %q: %v", prompt.id, err)
|
||||
}
|
||||
if prepared.OutputContract.SchemaPath != filepath.Base(semanticreconcile.SchemaAssetPath) {
|
||||
t.Fatalf("prompt %q schema = %q, want generic reconciliation schema", prompt.id, prepared.OutputContract.SchemaPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionSpellValidatorsPrepareFromMaterializedCatalog(t *testing.T) {
|
||||
|
||||
@@ -400,6 +400,9 @@ func (referenceContractCodecA) Encode(stateTestArtifact) ([]byte, error) {
|
||||
func (referenceContractCodecA) Decode([]byte) (stateTestArtifact, error) {
|
||||
return stateTestArtifact{Value: "ok"}, nil
|
||||
}
|
||||
func (codec referenceContractCodecA) DecodeCandidate(content []byte) (stateTestArtifact, error) {
|
||||
return codec.Decode(content)
|
||||
}
|
||||
|
||||
func (referenceContractCodecB) Kind() contracts.ArtifactKind { return referenceContractKindBeta }
|
||||
func (referenceContractCodecB) Schema() contracts.ArtifactSchema {
|
||||
@@ -415,6 +418,9 @@ func (referenceContractCodecB) Encode(stateTestArtifact) ([]byte, error) {
|
||||
func (referenceContractCodecB) Decode([]byte) (stateTestArtifact, error) {
|
||||
return stateTestArtifact{Value: "ok"}, nil
|
||||
}
|
||||
func (codec referenceContractCodecB) DecodeCandidate(content []byte) (stateTestArtifact, error) {
|
||||
return codec.Decode(content)
|
||||
}
|
||||
|
||||
func referenceContractLane(t *testing.T, resolved pipeline.ResolvedPipeline, id string) pipeline.ResolvedArtifactLane {
|
||||
t.Helper()
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/checkpoint"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkplan"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
@@ -147,7 +148,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
||||
machineOutput := fs.Bool("json", false, "write the successful run result as JSON")
|
||||
debug := fs.Bool("debug", false, "write a debug bundle")
|
||||
debugDir := fs.String("debug-dir", "", "debug bundle directory")
|
||||
llmProfile := fs.String("llm-profile", "", "LLM profile override")
|
||||
llmProfile := singleValueFlag{name: "--llm-profile"}
|
||||
reasoningEffort := singleValueFlag{name: "--reasoning-effort"}
|
||||
clearReasoningEffort := fs.Bool("clear-reasoning-effort", false, "clear the LLM profile reasoning effort")
|
||||
resume := fs.Bool("resume", false, "reuse compatible recorded checkpoints")
|
||||
@@ -157,6 +158,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
||||
referenceFlags := stringListFlag{}
|
||||
withoutReferenceFlags := stringListFlag{}
|
||||
fs.Var(&requestedSessionID, "session-id", "prompt session identifier")
|
||||
fs.Var(&llmProfile, "llm-profile", "LLM profile override")
|
||||
fs.Var(&reasoningEffort, "reasoning-effort", "reasoning effort override")
|
||||
fs.Var(&chunkCache, "chunk_cache", "chunk plan cache mode: auto, bypass, or refresh")
|
||||
fs.Var(&referenceFlags, "reference", "reference binding, as slot=path, chunk.slot=path, merge.slot=path, lane.slot=path, lane.extract.slot=path, lane.merge.slot=path, or lane.normalize.slot=path")
|
||||
@@ -203,6 +205,10 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
||||
fmt.Fprintln(stderr, "notarius: --session-id must not be empty")
|
||||
return 2
|
||||
}
|
||||
if llmProfile.set && strings.TrimSpace(llmProfile.value) == "" {
|
||||
fmt.Fprintln(stderr, "notarius: --llm-profile must not be empty")
|
||||
return 2
|
||||
}
|
||||
if reasoningEffort.set && *clearReasoningEffort {
|
||||
fmt.Fprintln(stderr, "notarius: --reasoning-effort cannot be combined with --clear-reasoning-effort")
|
||||
return 2
|
||||
@@ -338,7 +344,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
||||
PipelineID: pipelineID,
|
||||
Only: only,
|
||||
Catalog: catalog,
|
||||
LLMProfileOverride: *llmProfile,
|
||||
LLMProfileOverride: strings.TrimSpace(llmProfile.value),
|
||||
ReferenceOverrides: referenceOverrides,
|
||||
ReferenceUnbinds: referenceUnbinds,
|
||||
})
|
||||
@@ -426,7 +432,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
||||
if err != nil {
|
||||
return failPipelineCommand(stderr, commandState, terminalWriter, err)
|
||||
}
|
||||
checkpointRecorder, checkpointLoader, err := checkpointHandlersForRun(effective.Config.Cache.Checkpoints, opts, effective.ResolvedPipeline, prepared.CheckpointFingerprints(), llmFingerprints, rawInput, only, llmProfiles, strings.TrimSpace(*llmProfile), effectiveSessionID, runtimeOverrides, *resume)
|
||||
checkpointRecorder, checkpointLoader, err := checkpointHandlersForRun(effective.Config.Cache.Checkpoints, opts, effective.ResolvedPipeline, prepared.CheckpointFingerprints(), llmFingerprints, rawInput, only, llmProfiles, strings.TrimSpace(llmProfile.value), effectiveSessionID, runtimeOverrides, *resume)
|
||||
if err != nil {
|
||||
return failPipelineCommand(stderr, commandState, terminalWriter, err)
|
||||
}
|
||||
@@ -752,17 +758,10 @@ func configSource(configPath string) string {
|
||||
}
|
||||
|
||||
func writeOutputFiles(runOutputDir string, files []contracts.OutputFile) error {
|
||||
type outputTarget struct {
|
||||
path string
|
||||
file contracts.OutputFile
|
||||
}
|
||||
targets := make([]outputTarget, 0, len(files))
|
||||
for _, file := range files {
|
||||
targetPath, err := outputFilePath(runOutputDir, file.Name)
|
||||
if err != nil {
|
||||
if _, err := outputFilePath(runOutputDir, file.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
targets = append(targets, outputTarget{path: targetPath, file: file})
|
||||
}
|
||||
|
||||
outputParent := filepath.Dir(runOutputDir)
|
||||
@@ -775,12 +774,9 @@ func writeOutputFiles(runOutputDir string, files []contracts.OutputFile) error {
|
||||
}
|
||||
return fmt.Errorf("create output run directory %q: %w", runOutputDir, err)
|
||||
}
|
||||
for _, target := range targets {
|
||||
if err := os.MkdirAll(filepath.Dir(target.path), 0o755); err != nil {
|
||||
return fmt.Errorf("create output directory %q: %w", filepath.Dir(target.path), err)
|
||||
}
|
||||
if err := writeFileAtomic(target.path, target.file.Bytes, 0o644); err != nil {
|
||||
return fmt.Errorf("write output file %q: %w", target.file.Name, err)
|
||||
for _, file := range files {
|
||||
if err := fileio.WriteBytes(runOutputDir, file.Name, file.Bytes, 0o755, 0o644); err != nil {
|
||||
return fmt.Errorf("write output file %q: %w", file.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -823,38 +819,6 @@ func outputFilePath(runOutputDir, logicalName string) (string, error) {
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
temp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
removeTemp := true
|
||||
defer func() {
|
||||
if removeTemp {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Chmod(perm); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempPath, path); err != nil {
|
||||
return err
|
||||
}
|
||||
removeTemp = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func reorderRunArgs(args []string) []string {
|
||||
var flags []string
|
||||
var positionals []string
|
||||
|
||||
@@ -43,6 +43,12 @@ func TestRunControlsRejectSyntaxWithoutAllocatingState(t *testing.T) {
|
||||
{name: "blank session ID", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--session-id", ""}
|
||||
}},
|
||||
{name: "blank LLM profile", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--llm-profile", ""}
|
||||
}},
|
||||
{name: "whitespace LLM profile", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--llm-profile", " \t "}
|
||||
}},
|
||||
{name: "multiple pipeline IDs", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "extra", "--config", roots.config, "--input", roots.input}
|
||||
}},
|
||||
@@ -253,7 +259,7 @@ func TestRunLLMProfileOverrideAndValidationUseInjectedBoundaries(t *testing.T) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", "override-profile"}, &stdout, &stderr, opts)
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", " override-profile "}, &stdout, &stderr, opts)
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
@@ -38,10 +38,29 @@ func TestWriteOutputFilesSupportsNestedLogicalPaths(t *testing.T) {
|
||||
if err := writeOutputFiles(runPath, []contracts.OutputFile{{Name: "nested/result.json", Bytes: []byte("result")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(runPath, "nested", "result.json"))
|
||||
resultPath := filepath.Join(runPath, "nested", "result.json")
|
||||
data, err := os.ReadFile(resultPath)
|
||||
if err != nil || string(data) != "result" {
|
||||
t.Fatalf("nested output = %q, %v", data, err)
|
||||
}
|
||||
for path, want := range map[string]os.FileMode{runPath: 0o755, filepath.Join(runPath, "nested"): 0o755, resultPath: 0o644} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != want {
|
||||
t.Fatalf("%s mode = %#o, want %#o", path, info.Mode().Perm(), want)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(runPath, "nested"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry.Name(), ".tmp-") {
|
||||
t.Fatalf("temporary file remains: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteOutputFilesRejectsUnsafeNamesBeforeAllocatingRunDirectory(t *testing.T) {
|
||||
@@ -75,7 +94,7 @@ func TestWriteOutputFilesRetainsNewPartialDirectoryAndPreservesSibling(t *testin
|
||||
{Name: "blocked", Bytes: []byte("partial output")},
|
||||
{Name: "blocked/nested.json", Bytes: []byte("unreachable")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "create output directory") {
|
||||
if err == nil || !strings.Contains(err.Error(), `write output file "blocked/nested.json"`) {
|
||||
t.Fatalf("writeOutputFiles() error = %v, want later directory failure", err)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(runPath, "blocked")); err != nil || string(got) != "partial output" {
|
||||
|
||||
@@ -410,8 +410,8 @@ func TestMaintainedProductionOverlayRunAlignsGroundingValidationAndProvenance(t
|
||||
t.Fatalf("spell requests = %d, want one", len(requests))
|
||||
}
|
||||
catalogInput, ok := requests[0].Inputs[spellcatalog.SpellCatalogReferenceSlot]
|
||||
if !ok || !strings.Contains(string(catalogInput.Content), "Aegis of Emberfall") || strings.Contains(string(catalogInput.Content), "Emberfall Aegis") {
|
||||
t.Fatalf("spell catalog prompt input = %#v, want canonical overlay name without alias", catalogInput)
|
||||
if !ok || !strings.Contains(string(catalogInput.Content), `"canonical_name":"Aegis of Emberfall"`) || !strings.Contains(string(catalogInput.Content), `"aliases":["Emberfall Aegis"]`) {
|
||||
t.Fatalf("spell catalog prompt input = %#v, want canonical overlay name and recognition alias", catalogInput)
|
||||
}
|
||||
artifact := readProductionJSON[dnd.SpellList](t, filepath.Join(runRoot, "lanes", "spells.json"))
|
||||
if len(artifact.SpellCasts) != 1 || artifact.SpellCasts[0].Spell != "Aegis of Emberfall" {
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestProductionSpellCatalogValidationRetries(t *testing.T) {
|
||||
t.Fatalf("rejection = %#v, want exhausted unknown-spell rejection", rejection)
|
||||
}
|
||||
if len(output.Warnings) != 0 {
|
||||
t.Fatalf("warnings = %#v, want no warnings from rejected attempts", output.Warnings)
|
||||
t.Fatalf("warnings = %#v, want no emitted warnings from rejected attempts", output.Warnings)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -955,6 +955,9 @@ func (stateTestCodec) Encode(v stateTestArtifact) ([]byte, error) {
|
||||
func (stateTestCodec) Decode([]byte) (stateTestArtifact, error) {
|
||||
return stateTestArtifact{Value: "ok"}, nil
|
||||
}
|
||||
func (codec stateTestCodec) DecodeCandidate(content []byte) (stateTestArtifact, error) {
|
||||
return codec.Decode(content)
|
||||
}
|
||||
|
||||
type stateTestExtractor struct{ harness *stateTestHarness }
|
||||
|
||||
|
||||
@@ -427,6 +427,10 @@ func (effectiveCodec) Decode(content []byte) (effectiveArtifact, error) {
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (codec effectiveCodec) DecodeCandidate(content []byte) (effectiveArtifact, error) {
|
||||
return codec.Decode(content)
|
||||
}
|
||||
|
||||
type effectiveInput struct{ key string }
|
||||
|
||||
func (m effectiveInput) Key() string { return m.key }
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -349,6 +350,12 @@ func ParseFileConfigYAML(data []byte) (FileConfig, error) {
|
||||
if err := decoder.Decode(&fileCfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("decode yaml: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err == nil {
|
||||
return FileConfig{}, fmt.Errorf("config must contain exactly one YAML document")
|
||||
} else if err != io.EOF {
|
||||
return FileConfig{}, fmt.Errorf("decode trailing yaml document: %w", err)
|
||||
}
|
||||
return fileCfg, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,28 @@ func TestFileConfigMinimalVersion4AppliesOverDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigYAMLRejectsAdditionalDocuments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "trailing whitespace", source: "version: 4\n\n \t", wantErr: false},
|
||||
{name: "trailing comment", source: "version: 4\n# trailing comment\n", wantErr: false},
|
||||
{name: "second valid document", source: "version: 4\n---\nversion: 4\n", wantErr: true},
|
||||
{name: "second empty document", source: "version: 4\n---\n", wantErr: true},
|
||||
{name: "second malformed document", source: "version: 4\n---\nversion: [\n", wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ParseFileConfigYAML([]byte(tt.source))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("ParseFileConfigYAML() error = %v, want error=%t", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilePipelineLLMProfileIsPresenceAwareAndDetached(t *testing.T) {
|
||||
const pipelineYAML = `version: 4
|
||||
pipelines:
|
||||
|
||||
@@ -10,6 +10,38 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EncodePathComponent returns a filesystem-safe, injective representation of
|
||||
// one logical path component.
|
||||
func EncodePathComponent(value string) string {
|
||||
if value == "" {
|
||||
return "%"
|
||||
}
|
||||
|
||||
const hexadecimal = "0123456789ABCDEF"
|
||||
var out strings.Builder
|
||||
for index := 0; index < len(value); index++ {
|
||||
byteValue := value[index]
|
||||
switch {
|
||||
case byteValue >= 'a' && byteValue <= 'z', byteValue >= 'A' && byteValue <= 'Z', byteValue >= '0' && byteValue <= '9', byteValue == '-', byteValue == '_':
|
||||
out.WriteByte(byteValue)
|
||||
case byteValue == '.' && safePathDot(value, index):
|
||||
out.WriteByte(byteValue)
|
||||
default:
|
||||
out.WriteByte('%')
|
||||
out.WriteByte(hexadecimal[byteValue>>4])
|
||||
out.WriteByte(hexadecimal[byteValue&0x0f])
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func safePathDot(value string, index int) bool {
|
||||
if value == "." || value == ".." {
|
||||
return false
|
||||
}
|
||||
return (index == 0 || value[index-1] != '.') && (index+1 == len(value) || value[index+1] != '.')
|
||||
}
|
||||
|
||||
func SafePath(root, name string) (string, error) {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
|
||||
@@ -15,6 +15,39 @@ func TestSafePathRejectsUnsafeNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodePathComponentIsInjectiveAndSafe(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seen := make(map[string]string)
|
||||
for _, test := range []struct {
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{value: "", want: "%"},
|
||||
{value: ".", want: "%2E"},
|
||||
{value: "..", want: "%2E%2E"},
|
||||
{value: "_", want: "_"},
|
||||
{value: "a..b", want: "a%2E%2Eb"},
|
||||
{value: "safe.identifier-9", want: "safe.identifier-9"},
|
||||
{value: "left/right", want: "left%2Fright"},
|
||||
{value: "%", want: "%25"},
|
||||
{value: "~", want: "%7E"},
|
||||
{value: " a ", want: "%20a%20"},
|
||||
{value: "é", want: "%C3%A9"},
|
||||
} {
|
||||
got := EncodePathComponent(test.value)
|
||||
if got != test.want {
|
||||
t.Errorf("EncodePathComponent(%q) = %q, want %q", test.value, got, test.want)
|
||||
}
|
||||
if previous, ok := seen[got]; ok {
|
||||
t.Errorf("EncodePathComponent(%q) = %q, collides with %q", test.value, got, previous)
|
||||
}
|
||||
seen[got] = test.value
|
||||
if _, err := SafePath(root, "components/"+got); err != nil {
|
||||
t.Errorf("EncodePathComponent(%q) produced unsafe component %q: %v", test.value, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteBytesIsAtomicAndUsesRequestedModes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := WriteBytes(root, "nested/value", []byte("value"), 0o700, 0o600); err != nil {
|
||||
|
||||
@@ -475,36 +475,9 @@ func laneManifestPath(stage string, stepID string, laneID string) string {
|
||||
|
||||
func lanePayloadPath(stage string, stepID string, laneID string, file string) string {
|
||||
if strings.TrimSpace(stepID) == "" {
|
||||
return path.Join(stage, checkpointPathComponent(laneID), file)
|
||||
return path.Join(stage, fileio.EncodePathComponent(laneID), file)
|
||||
}
|
||||
return path.Join(stage, checkpointPathComponent(stepID), checkpointPathComponent(laneID), file)
|
||||
}
|
||||
|
||||
func checkpointPathComponent(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "_"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '-' || r == '_' || r == '.':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteString(fmt.Sprintf("~%x", r))
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if out == "." || out == ".." || strings.Contains(out, "..") {
|
||||
return "_"
|
||||
}
|
||||
return out
|
||||
return path.Join(stage, fileio.EncodePathComponent(stepID), fileio.EncodePathComponent(laneID), file)
|
||||
}
|
||||
|
||||
func contentDigest(content []byte) string {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
@@ -66,6 +68,56 @@ func TestStepAwareRecorderAndLoaderIsolateLaneState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepAwareCheckpointPreservesDistinctDotIdentities(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
identity := testIdentity(t)
|
||||
recorder, err := NewFilesystemRecorder(root, identity)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stepRecorder := recorder.(pipeline.StepCheckpointRecorder)
|
||||
for _, test := range []struct {
|
||||
stepID string
|
||||
content string
|
||||
}{
|
||||
{stepID: ".", content: `{"identity":"dot"}`},
|
||||
{stepID: "..", content: `{"identity":"dot-dot"}`},
|
||||
} {
|
||||
artifact := pipeline.CheckpointArtifact{
|
||||
LaneID: "lane", ModuleKey: "normalize-module", SourceID: "source", ChunkID: "chunk", ChunkRef: source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}, SchemaDigest: "sha256:schema",
|
||||
Artifact: contracts.SerializedArtifact{Kind: "kind", Schema: contracts.ArtifactSchema{ID: "schema", Name: "Schema", Version: "1"}, MediaType: "application/json", Content: []byte(test.content)},
|
||||
}
|
||||
if err := stepRecorder.NormalizeSucceededForStep(test.stepID, "lane", "normalize-module", nil, artifact, nil); err != nil {
|
||||
t.Fatalf("record %q: %v", test.stepID, err)
|
||||
}
|
||||
}
|
||||
|
||||
loader, err := NewFilesystemLoader(root, identity)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
stepID string
|
||||
content string
|
||||
path string
|
||||
}{
|
||||
{stepID: ".", content: `{"identity":"dot"}`, path: "%2E"},
|
||||
{stepID: "..", content: `{"identity":"dot-dot"}`, path: "%2E%2E"},
|
||||
} {
|
||||
loaded, decision := loader.AcceptedNormalize(test.stepID, "lane", "normalize-module")
|
||||
if !decision.Reused || string(loaded.Output.Artifact.Content) != test.content {
|
||||
t.Errorf("load %q = %#v, decision=%#v", test.stepID, loaded, decision)
|
||||
}
|
||||
relative, err := identity.RelativePath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, relative, "normalize", test.path, "lane", "manifest.json")); err != nil {
|
||||
t.Errorf("checkpoint for %q: %v", test.stepID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointSchemaCompatibilityIdentifiers(t *testing.T) {
|
||||
if WorkspaceSchemaVersion != "notarius.workspace.v3" || WorkspaceSchemaVersionV2 != "notarius.workspace.v2" || WorkspaceSchemaVersionV1 != "notarius.workspace.v1" {
|
||||
t.Fatal("checkpoint schema identifiers are incorrect")
|
||||
|
||||
@@ -102,11 +102,11 @@ func Build(request BuildRequest) (ChunkMap, error) {
|
||||
Annotations: source.CloneChunkAnnotations(chunk.Annotations),
|
||||
}
|
||||
}
|
||||
canonical, err := canonicalize(value)
|
||||
canonical, err := canonicalizeOwned(value)
|
||||
if err != nil {
|
||||
return ChunkMap{}, fmt.Errorf("validate chunk map: %w", err)
|
||||
}
|
||||
return clone(canonical), nil
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
// Serialize builds and encodes the framework-owned serialized artifact.
|
||||
@@ -132,7 +132,7 @@ func (c *Codec) Encode(value ChunkMap) ([]byte, error) {
|
||||
if _, err := c.schemaBytes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonical, err := canonicalize(clone(value))
|
||||
canonical, err := canonicalize(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode source chunk map: %w", err)
|
||||
}
|
||||
@@ -163,11 +163,11 @@ func (c *Codec) Decode(content []byte) (ChunkMap, error) {
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
return ChunkMap{}, fmt.Errorf("decode source chunk map: multiple JSON values")
|
||||
}
|
||||
canonical, err := canonicalize(value)
|
||||
canonical, err := canonicalizeOwned(value)
|
||||
if err != nil {
|
||||
return ChunkMap{}, fmt.Errorf("decode source chunk map: %w", err)
|
||||
}
|
||||
return clone(canonical), nil
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func (c *Codec) schemaBytes() ([]byte, error) {
|
||||
@@ -241,6 +241,10 @@ func hasRequiredFields(required []string) bool {
|
||||
}
|
||||
|
||||
func canonicalize(value ChunkMap) (ChunkMap, error) {
|
||||
return canonicalizeOwned(clone(value))
|
||||
}
|
||||
|
||||
func canonicalizeOwned(value ChunkMap) (ChunkMap, error) {
|
||||
if err := requireIdentity("source_id", value.SourceID); err != nil {
|
||||
return ChunkMap{}, err
|
||||
}
|
||||
|
||||
@@ -56,6 +56,15 @@ func TestCodecRoundTripsValidFixture(t *testing.T) {
|
||||
if !bytes.Equal(encoded, bytes.TrimSpace(fixture)) {
|
||||
t.Fatalf("fixture does not use canonical encoding\nwant: %s\n got: %s", fixture, encoded)
|
||||
}
|
||||
value.PlanAnnotations["test/chunker"][0] = '['
|
||||
value.Chunks[0].Annotations["test/chunker"][0] = '['
|
||||
decoded, err := codec.Decode(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(encoded) after mutation error = %v", err)
|
||||
}
|
||||
if string(decoded.PlanAnnotations["test/chunker"]) != `{"label":"fixture"}` || string(decoded.Chunks[0].Annotations["test/chunker"]) != `{"category":"sample"}` {
|
||||
t.Fatalf("Decode() reused mutable chunk-map storage: %#v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCanonicalizesAnnotationFormatting(t *testing.T) {
|
||||
|
||||
@@ -50,6 +50,13 @@ type ArtifactCodec[T any] interface {
|
||||
Decode([]byte) (T, error)
|
||||
}
|
||||
|
||||
// CandidateArtifactCodec extends an artifact codec with strict representation
|
||||
// decoding for values that have not yet passed semantic validation.
|
||||
type CandidateArtifactCodec[T any] interface {
|
||||
ArtifactCodec[T]
|
||||
DecodeCandidate([]byte) (T, error)
|
||||
}
|
||||
|
||||
// DigestArtifactSchema returns the SHA-256 digest of the exact JSON Schema
|
||||
// bytes. Schema formatting is therefore part of the registered identity.
|
||||
func DigestArtifactSchema(schema ArtifactSchema) string {
|
||||
|
||||
@@ -2,24 +2,8 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "notarius.source.evidence_context",
|
||||
"title": "notarius_source_evidence_context_v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["source_id", "source_digest", "window_units", "selected_lanes", "contexts"],
|
||||
"properties": {
|
||||
"source_id": {"type": "string", "minLength": 1},
|
||||
"source_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
|
||||
"window_units": {"type": "integer", "minimum": 0},
|
||||
"selected_lanes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"contexts": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/context"}
|
||||
}
|
||||
},
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/unit"},
|
||||
"$defs": {
|
||||
"source_ref": {
|
||||
"type": "object",
|
||||
@@ -42,25 +26,6 @@
|
||||
"ref": {"$ref": "#/$defs/source_ref"},
|
||||
"metadata": {"type": "object", "additionalProperties": true}
|
||||
}
|
||||
},
|
||||
"evidence_ref": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["lane_id", "source_ref"],
|
||||
"properties": {
|
||||
"lane_id": {"type": "string", "minLength": 1},
|
||||
"source_ref": {"$ref": "#/$defs/source_ref"}
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["context_ref", "evidence_refs", "units"],
|
||||
"properties": {
|
||||
"context_ref": {"$ref": "#/$defs/source_ref"},
|
||||
"evidence_refs": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/evidence_ref"}},
|
||||
"units": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/unit"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,118 +3,62 @@ package evidencecontext
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
type contribution struct {
|
||||
laneID string
|
||||
ref source.SourceRef
|
||||
type expandedRange struct {
|
||||
startPos int
|
||||
endPos int
|
||||
}
|
||||
|
||||
type expandedRange struct {
|
||||
startPos int
|
||||
endPos int
|
||||
contributions []contribution
|
||||
}
|
||||
|
||||
// Build validates accepted direct references, expands them by source-document
|
||||
// position, and returns their deterministic context union.
|
||||
// Build validates projected source references, expands them by source-document
|
||||
// position, and returns their ordered union as an owned source-unit excerpt.
|
||||
func Build(request BuildRequest) (Document, error) {
|
||||
if request.WindowUnits < 0 {
|
||||
return Document{}, fmt.Errorf("window_units must not be negative")
|
||||
}
|
||||
lanes, err := normalizeSelectedLanes(request.SelectedLanes)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
return nil, fmt.Errorf("window_units must not be negative")
|
||||
}
|
||||
if err := source.ValidateDocument(request.Source); err != nil {
|
||||
return Document{}, fmt.Errorf("validate source document: %w", err)
|
||||
return nil, fmt.Errorf("validate source document: %w", err)
|
||||
}
|
||||
digest, err := source.DigestDocument(request.Source)
|
||||
if err != nil {
|
||||
return Document{}, fmt.Errorf("digest source document: %w", err)
|
||||
return nil, fmt.Errorf("digest source document: %w", err)
|
||||
}
|
||||
if digest != request.Source.Digest {
|
||||
return Document{}, fmt.Errorf("source digest does not match source document digest")
|
||||
return nil, fmt.Errorf("source digest does not match source document digest")
|
||||
}
|
||||
|
||||
selected := make(map[string]struct{}, len(lanes))
|
||||
for _, laneID := range lanes {
|
||||
selected[laneID] = struct{}{}
|
||||
}
|
||||
index := source.NewDocumentIndex(request.Source)
|
||||
seen := make(map[evidenceKey]struct{})
|
||||
contributions := make([]contribution, 0)
|
||||
for laneIndex, laneEvidence := range request.LaneEvidence {
|
||||
laneID := strings.TrimSpace(laneEvidence.LaneID)
|
||||
if _, ok := selected[laneID]; !ok {
|
||||
return Document{}, fmt.Errorf("lane evidence[%d] lane %q is not selected", laneIndex, laneID)
|
||||
ranges := make([]expandedRange, 0, len(request.SourceRefs))
|
||||
for refIndex, ref := range request.SourceRefs {
|
||||
if err := index.ValidateRef(ref); err != nil {
|
||||
return nil, fmt.Errorf("source reference[%d]: %w", refIndex, err)
|
||||
}
|
||||
for refIndex, ref := range laneEvidence.SourceRefs {
|
||||
if err := index.ValidateRef(ref); err != nil {
|
||||
return Document{}, fmt.Errorf("lane %q source reference[%d]: %w", laneID, refIndex, err)
|
||||
startPos, _ := index.Position(ref.StartUnitID)
|
||||
endPos, _ := index.Position(ref.EndUnitID)
|
||||
ranges = append(ranges, expandedRange{
|
||||
startPos: expandStart(startPos, request.WindowUnits),
|
||||
endPos: expandEnd(endPos, len(request.Source.Units), request.WindowUnits),
|
||||
})
|
||||
}
|
||||
|
||||
merged := mergeRanges(ranges)
|
||||
unitCount := 0
|
||||
for _, value := range merged {
|
||||
unitCount += value.endPos - value.startPos + 1
|
||||
}
|
||||
document := make(Document, 0, unitCount)
|
||||
for _, value := range merged {
|
||||
for position := value.startPos; position <= value.endPos; position++ {
|
||||
unit, err := cloneSourceUnit(request.Source.Units[position])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone source unit at position %d: %w", position, err)
|
||||
}
|
||||
key := evidenceKey{laneID: laneID, ref: ref}
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
startPos, _ := index.Position(ref.StartUnitID)
|
||||
endPos, _ := index.Position(ref.EndUnitID)
|
||||
contributions = append(contributions, contribution{laneID: laneID, ref: ref, startPos: expandStart(startPos, request.WindowUnits), endPos: expandEnd(endPos, len(request.Source.Units), request.WindowUnits)})
|
||||
document = append(document, unit)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(contributions, func(i, j int) bool { return lessContribution(contributions[i], contributions[j]) })
|
||||
document := Document{
|
||||
SourceID: request.Source.ID,
|
||||
SourceDigest: digest,
|
||||
WindowUnits: request.WindowUnits,
|
||||
SelectedLanes: lanes,
|
||||
Contexts: make([]Context, 0),
|
||||
}
|
||||
for _, rangeValue := range mergeRanges(contributions) {
|
||||
context, err := buildContext(request.Source, rangeValue)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
document.Contexts = append(document.Contexts, context)
|
||||
}
|
||||
canonical, err := canonicalize(document)
|
||||
if err != nil {
|
||||
return Document{}, fmt.Errorf("validate evidence context: %w", err)
|
||||
}
|
||||
return clone(canonical)
|
||||
}
|
||||
|
||||
type evidenceKey struct {
|
||||
laneID string
|
||||
ref source.SourceRef
|
||||
}
|
||||
|
||||
func normalizeSelectedLanes(values []string) ([]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, fmt.Errorf("selected_lanes must not be empty")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
lanes := make([]string, 0, len(values))
|
||||
for index, raw := range values {
|
||||
laneID := strings.TrimSpace(raw)
|
||||
if laneID == "" {
|
||||
return nil, fmt.Errorf("selected_lanes[%d] must not be empty", index)
|
||||
}
|
||||
if _, exists := seen[laneID]; exists {
|
||||
return nil, fmt.Errorf("selected_lanes lane %q is duplicated", laneID)
|
||||
}
|
||||
seen[laneID] = struct{}{}
|
||||
lanes = append(lanes, laneID)
|
||||
}
|
||||
sort.Strings(lanes)
|
||||
return lanes, nil
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func expandStart(position, window int) int {
|
||||
@@ -132,65 +76,25 @@ func expandEnd(position, length, window int) int {
|
||||
return position + window
|
||||
}
|
||||
|
||||
func lessContribution(left, right contribution) bool {
|
||||
if left.startPos != right.startPos {
|
||||
return left.startPos < right.startPos
|
||||
}
|
||||
if left.endPos != right.endPos {
|
||||
return left.endPos < right.endPos
|
||||
}
|
||||
return lessEvidenceRef(EvidenceRef{LaneID: left.laneID, SourceRef: left.ref}, EvidenceRef{LaneID: right.laneID, SourceRef: right.ref})
|
||||
}
|
||||
|
||||
func mergeRanges(values []contribution) []expandedRange {
|
||||
func mergeRanges(values []expandedRange) []expandedRange {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
ranges := make([]expandedRange, 0, len(values))
|
||||
sort.Slice(values, func(i, j int) bool {
|
||||
if values[i].startPos != values[j].startPos {
|
||||
return values[i].startPos < values[j].startPos
|
||||
}
|
||||
return values[i].endPos < values[j].endPos
|
||||
})
|
||||
merged := make([]expandedRange, 0, len(values))
|
||||
for _, value := range values {
|
||||
if len(ranges) == 0 || value.startPos > ranges[len(ranges)-1].endPos+1 {
|
||||
ranges = append(ranges, expandedRange{startPos: value.startPos, endPos: value.endPos, contributions: []contribution{value}})
|
||||
if len(merged) == 0 || value.startPos > merged[len(merged)-1].endPos+1 {
|
||||
merged = append(merged, value)
|
||||
continue
|
||||
}
|
||||
current := &ranges[len(ranges)-1]
|
||||
if value.endPos > current.endPos {
|
||||
current.endPos = value.endPos
|
||||
if value.endPos > merged[len(merged)-1].endPos {
|
||||
merged[len(merged)-1].endPos = value.endPos
|
||||
}
|
||||
current.contributions = append(current.contributions, value)
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
func buildContext(document *source.SourceDocument, value expandedRange) (Context, error) {
|
||||
evidenceRefs := make([]EvidenceRef, 0, len(value.contributions))
|
||||
for _, contribution := range value.contributions {
|
||||
evidenceRefs = append(evidenceRefs, EvidenceRef{LaneID: contribution.laneID, SourceRef: contribution.ref})
|
||||
}
|
||||
sort.Slice(evidenceRefs, func(i, j int) bool { return lessEvidenceRef(evidenceRefs[i], evidenceRefs[j]) })
|
||||
units := make([]source.SourceUnit, 0, value.endPos-value.startPos+1)
|
||||
for position := value.startPos; position <= value.endPos; position++ {
|
||||
unit, err := cloneSourceUnit(document.Units[position])
|
||||
if err != nil {
|
||||
return Context{}, fmt.Errorf("clone source unit at position %d: %w", position, err)
|
||||
}
|
||||
units = append(units, unit)
|
||||
}
|
||||
return Context{
|
||||
ContextRef: source.SourceRef{SourceID: document.ID, StartUnitID: units[0].ID, EndUnitID: units[len(units)-1].ID},
|
||||
EvidenceRefs: evidenceRefs,
|
||||
Units: units,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func lessEvidenceRef(left, right EvidenceRef) bool {
|
||||
if left.LaneID != right.LaneID {
|
||||
return left.LaneID < right.LaneID
|
||||
}
|
||||
if left.SourceRef.SourceID != right.SourceRef.SourceID {
|
||||
return left.SourceRef.SourceID < right.SourceRef.SourceID
|
||||
}
|
||||
if left.SourceRef.StartUnitID != right.SourceRef.StartUnitID {
|
||||
return left.SourceRef.StartUnitID < right.SourceRef.StartUnitID
|
||||
}
|
||||
return left.SourceRef.EndUnitID < right.SourceRef.EndUnitID
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -18,8 +17,6 @@ import (
|
||||
//go:embed assets/schemas/source_evidence_context.v1.json
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
|
||||
var (
|
||||
loadSchemaOnce sync.Once
|
||||
loadedSchema []byte
|
||||
@@ -78,26 +75,26 @@ func (c *Codec) Encode(value Document) ([]byte, error) {
|
||||
|
||||
func (c *Codec) Decode(content []byte) (Document, error) {
|
||||
if _, err := c.schemaBytes(); err != nil {
|
||||
return Document{}, err
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSchemaInstance(content); err != nil {
|
||||
return Document{}, fmt.Errorf("decode evidence context: %w", err)
|
||||
return nil, fmt.Errorf("decode evidence context: %w", err)
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||
decoder.DisallowUnknownFields()
|
||||
var value Document
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return Document{}, fmt.Errorf("decode evidence context: %w", err)
|
||||
return nil, fmt.Errorf("decode evidence context: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
return Document{}, fmt.Errorf("decode evidence context: multiple JSON values")
|
||||
return nil, fmt.Errorf("decode evidence context: multiple JSON values")
|
||||
}
|
||||
canonical, err := canonicalize(value)
|
||||
canonical, err := canonicalizeOwned(value)
|
||||
if err != nil {
|
||||
return Document{}, fmt.Errorf("decode evidence context: %w", err)
|
||||
return nil, fmt.Errorf("decode evidence context: %w", err)
|
||||
}
|
||||
return clone(canonical)
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func (c *Codec) schemaBytes() ([]byte, error) {
|
||||
@@ -115,17 +112,16 @@ func loadAndCompileSchema() {
|
||||
return
|
||||
}
|
||||
var identity struct {
|
||||
ID string `json:"$id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Required []string `json:"required"`
|
||||
ID string `json:"$id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &identity); err != nil {
|
||||
loadSchemaErr = fmt.Errorf("decode source evidence context schema: %w", err)
|
||||
return
|
||||
}
|
||||
if identity.ID != SchemaID || identity.Title != SchemaName || identity.Type != "object" || !hasRequiredFields(identity.Required) {
|
||||
loadSchemaErr = fmt.Errorf("source evidence context schema identity or required fields are invalid")
|
||||
if identity.ID != SchemaID || identity.Title != SchemaName || identity.Type != "array" {
|
||||
loadSchemaErr = fmt.Errorf("source evidence context schema identity is invalid")
|
||||
return
|
||||
}
|
||||
schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
|
||||
@@ -158,165 +154,65 @@ func validateSchemaInstance(content []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasRequiredFields(required []string) bool {
|
||||
want := map[string]bool{"source_id": true, "source_digest": true, "window_units": true, "selected_lanes": true, "contexts": true}
|
||||
for _, field := range required {
|
||||
delete(want, field)
|
||||
}
|
||||
return len(want) == 0
|
||||
}
|
||||
|
||||
func canonicalize(value Document) (Document, error) {
|
||||
owned, err := clone(value)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
return nil, err
|
||||
}
|
||||
value = owned
|
||||
if err := requireIdentity("source_id", value.SourceID); err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
if !digestPattern.MatchString(value.SourceDigest) {
|
||||
return Document{}, fmt.Errorf("source_digest must be a sha256 digest")
|
||||
}
|
||||
if value.WindowUnits < 0 {
|
||||
return Document{}, fmt.Errorf("window_units must not be negative")
|
||||
}
|
||||
if err := validateSelectedLanes(value.SelectedLanes); err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
if value.Contexts == nil {
|
||||
value.Contexts = make([]Context, 0)
|
||||
}
|
||||
selected := make(map[string]struct{}, len(value.SelectedLanes))
|
||||
for _, laneID := range value.SelectedLanes {
|
||||
selected[laneID] = struct{}{}
|
||||
}
|
||||
seenUnits := make(map[int]struct{})
|
||||
for contextIndex := range value.Contexts {
|
||||
context, err := canonicalizeContext(value.SourceID, selected, seenUnits, value.Contexts[contextIndex], contextIndex)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
value.Contexts[contextIndex] = context
|
||||
}
|
||||
return value, nil
|
||||
return canonicalizeOwned(owned)
|
||||
}
|
||||
|
||||
func validateSelectedLanes(lanes []string) error {
|
||||
if len(lanes) == 0 {
|
||||
return fmt.Errorf("selected_lanes must not be empty")
|
||||
func canonicalizeOwned(value Document) (Document, error) {
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("document must be a JSON array")
|
||||
}
|
||||
for index, laneID := range lanes {
|
||||
if err := requireIdentity(fmt.Sprintf("selected_lanes[%d]", index), laneID); err != nil {
|
||||
return err
|
||||
}
|
||||
if index > 0 && lanes[index-1] >= laneID {
|
||||
return fmt.Errorf("selected_lanes must be unique and in lexical order")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalizeContext(sourceID string, selected map[string]struct{}, seenUnits map[int]struct{}, value Context, contextIndex int) (Context, error) {
|
||||
prefix := fmt.Sprintf("contexts[%d]", contextIndex)
|
||||
if len(value.EvidenceRefs) == 0 {
|
||||
return Context{}, fmt.Errorf("%s.evidence_refs must not be empty", prefix)
|
||||
}
|
||||
if len(value.Units) == 0 {
|
||||
return Context{}, fmt.Errorf("%s.units must not be empty", prefix)
|
||||
}
|
||||
if err := validateRefIdentity(sourceID, value.ContextRef, prefix+".context_ref"); err != nil {
|
||||
return Context{}, err
|
||||
}
|
||||
positions := make(map[int]int, len(value.Units))
|
||||
for unitIndex := range value.Units {
|
||||
unit, err := cloneSourceUnit(value.Units[unitIndex])
|
||||
if err != nil {
|
||||
return Context{}, fmt.Errorf("%s.units[%d]: %w", prefix, unitIndex, err)
|
||||
}
|
||||
seenUnitIDs := make(map[int]struct{}, len(value))
|
||||
sourceID := ""
|
||||
for unitIndex := range value {
|
||||
unit := value[unitIndex]
|
||||
if unit.ID <= 0 || strings.TrimSpace(unit.Kind) == "" || strings.TrimSpace(unit.Text) == "" {
|
||||
return Context{}, fmt.Errorf("%s.units[%d] has invalid required fields", prefix, unitIndex)
|
||||
return nil, fmt.Errorf("units[%d] has invalid required fields", unitIndex)
|
||||
}
|
||||
if err := validateRefIdentity(sourceID, unit.Ref, fmt.Sprintf("%s.units[%d].ref", prefix, unitIndex)); err != nil {
|
||||
return Context{}, err
|
||||
if err := validateUnitRef(unit, unitIndex); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if unit.Ref.StartUnitID != unit.ID || unit.Ref.EndUnitID != unit.ID {
|
||||
return Context{}, fmt.Errorf("%s.units[%d].ref must identify unit id %d", prefix, unitIndex, unit.ID)
|
||||
if sourceID == "" {
|
||||
sourceID = unit.Ref.SourceID
|
||||
} else if unit.Ref.SourceID != sourceID {
|
||||
return nil, fmt.Errorf("units[%d].ref.source_id must match units[0].ref.source_id", unitIndex)
|
||||
}
|
||||
if _, exists := positions[unit.ID]; exists {
|
||||
return Context{}, fmt.Errorf("%s.units contains duplicate unit id %d", prefix, unit.ID)
|
||||
}
|
||||
if _, exists := seenUnits[unit.ID]; exists {
|
||||
return Context{}, fmt.Errorf("contexts contain duplicate unit id %d", unit.ID)
|
||||
}
|
||||
positions[unit.ID] = unitIndex
|
||||
seenUnits[unit.ID] = struct{}{}
|
||||
value.Units[unitIndex] = unit
|
||||
}
|
||||
if value.ContextRef.StartUnitID != value.Units[0].ID || value.ContextRef.EndUnitID != value.Units[len(value.Units)-1].ID {
|
||||
return Context{}, fmt.Errorf("%s.context_ref must identify the first and last units", prefix)
|
||||
}
|
||||
for evidenceIndex := range value.EvidenceRefs {
|
||||
evidence := value.EvidenceRefs[evidenceIndex]
|
||||
if _, ok := selected[evidence.LaneID]; !ok {
|
||||
return Context{}, fmt.Errorf("%s.evidence_refs[%d].lane_id is not selected", prefix, evidenceIndex)
|
||||
}
|
||||
if err := requireIdentity(fmt.Sprintf("%s.evidence_refs[%d].lane_id", prefix, evidenceIndex), evidence.LaneID); err != nil {
|
||||
return Context{}, err
|
||||
}
|
||||
if err := validateRefIdentity(sourceID, evidence.SourceRef, fmt.Sprintf("%s.evidence_refs[%d].source_ref", prefix, evidenceIndex)); err != nil {
|
||||
return Context{}, err
|
||||
}
|
||||
start, startOK := positions[evidence.SourceRef.StartUnitID]
|
||||
end, endOK := positions[evidence.SourceRef.EndUnitID]
|
||||
if !startOK || !endOK || start > end {
|
||||
return Context{}, fmt.Errorf("%s.evidence_refs[%d].source_ref is outside context units", prefix, evidenceIndex)
|
||||
}
|
||||
if evidenceIndex > 0 && !lessEvidenceRef(value.EvidenceRefs[evidenceIndex-1], evidence) {
|
||||
return Context{}, fmt.Errorf("%s.evidence_refs must be unique and in deterministic order", prefix)
|
||||
if _, exists := seenUnitIDs[unit.ID]; exists {
|
||||
return nil, fmt.Errorf("units contains duplicate unit id %d", unit.ID)
|
||||
}
|
||||
seenUnitIDs[unit.ID] = struct{}{}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validateRefIdentity(sourceID string, ref source.SourceRef, field string) error {
|
||||
if ref.SourceID != sourceID {
|
||||
return fmt.Errorf("%s.source_id does not match source_id", field)
|
||||
func validateUnitRef(unit source.SourceUnit, unitIndex int) error {
|
||||
prefix := fmt.Sprintf("units[%d].ref", unitIndex)
|
||||
if strings.TrimSpace(unit.Ref.SourceID) == "" || strings.TrimSpace(unit.Ref.SourceID) != unit.Ref.SourceID {
|
||||
return fmt.Errorf("%s.source_id must be a non-empty trimmed string", prefix)
|
||||
}
|
||||
if ref.StartUnitID <= 0 || ref.EndUnitID <= 0 {
|
||||
return fmt.Errorf("%s endpoints must be positive", field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireIdentity(field, value string) error {
|
||||
if strings.TrimSpace(value) == "" || strings.TrimSpace(value) != value {
|
||||
return fmt.Errorf("%s must be a non-empty trimmed string", field)
|
||||
if unit.Ref.StartUnitID != unit.ID || unit.Ref.EndUnitID != unit.ID {
|
||||
return fmt.Errorf("%s must identify unit id %d", prefix, unit.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clone(value Document) (Document, error) {
|
||||
value.SelectedLanes = append([]string(nil), value.SelectedLanes...)
|
||||
if value.Contexts == nil {
|
||||
value.Contexts = make([]Context, 0)
|
||||
} else {
|
||||
contexts := make([]Context, len(value.Contexts))
|
||||
for contextIndex, context := range value.Contexts {
|
||||
contexts[contextIndex].ContextRef = context.ContextRef
|
||||
contexts[contextIndex].EvidenceRefs = append([]EvidenceRef(nil), context.EvidenceRefs...)
|
||||
contexts[contextIndex].Units = make([]source.SourceUnit, len(context.Units))
|
||||
for unitIndex, unit := range context.Units {
|
||||
cloned, err := cloneSourceUnit(unit)
|
||||
if err != nil {
|
||||
return Document{}, fmt.Errorf("clone contexts[%d].units[%d]: %w", contextIndex, unitIndex, err)
|
||||
}
|
||||
contexts[contextIndex].Units[unitIndex] = cloned
|
||||
}
|
||||
}
|
||||
value.Contexts = contexts
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return value, nil
|
||||
cloned := make(Document, len(value))
|
||||
for unitIndex, unit := range value {
|
||||
owned, err := cloneSourceUnit(unit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone units[%d]: %w", unitIndex, err)
|
||||
}
|
||||
cloned[unitIndex] = owned
|
||||
}
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
func cloneSourceUnit(unit source.SourceUnit) (source.SourceUnit, error) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package evidencecontext
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -12,126 +11,57 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
func TestBuildExpandsAndMergesEvidenceByDocumentPosition(t *testing.T) {
|
||||
func TestBuildSelectsExpandedSourceUnitUnion(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
window int
|
||||
evidence []LaneEvidence
|
||||
wantUnits [][]int
|
||||
wantRefs [][]EvidenceRef
|
||||
name string
|
||||
window int
|
||||
refs []source.SourceRef
|
||||
wantIDs []int
|
||||
}{
|
||||
{
|
||||
name: "zero window",
|
||||
evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}},
|
||||
wantUnits: [][]int{{3}},
|
||||
wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(3, 3)}}},
|
||||
},
|
||||
{
|
||||
name: "non monotonic ids use positions and clip boundaries",
|
||||
window: 1,
|
||||
evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}},
|
||||
wantUnits: [][]int{{10, 3, 30}},
|
||||
wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(3, 3)}}},
|
||||
},
|
||||
{
|
||||
name: "separate gaps stay separate",
|
||||
evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(10, 10), ref(50, 50)}}},
|
||||
wantUnits: [][]int{{10}, {50}},
|
||||
wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(10, 10)}}, {{LaneID: "npcs", SourceRef: ref(50, 50)}}},
|
||||
},
|
||||
{
|
||||
name: "overlapping windows merge",
|
||||
window: 1,
|
||||
evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3), ref(30, 30)}}},
|
||||
wantUnits: [][]int{{10, 3, 30, 7}},
|
||||
wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(3, 3)}, {LaneID: "npcs", SourceRef: ref(30, 30)}}},
|
||||
},
|
||||
{
|
||||
name: "contiguous windows merge",
|
||||
window: 1,
|
||||
evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(10, 10), ref(7, 7)}}},
|
||||
wantUnits: [][]int{{10, 3, 30, 7, 50}},
|
||||
wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(7, 7)}, {LaneID: "npcs", SourceRef: ref(10, 10)}}},
|
||||
},
|
||||
{
|
||||
name: "duplicate contributions retain unique lane attribution",
|
||||
evidence: []LaneEvidence{
|
||||
{LaneID: "spells", SourceRefs: []source.SourceRef{ref(30, 30), ref(30, 30)}},
|
||||
{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(30, 30)}},
|
||||
},
|
||||
wantUnits: [][]int{{30}},
|
||||
wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(30, 30)}, {LaneID: "spells", SourceRef: ref(30, 30)}}},
|
||||
},
|
||||
{
|
||||
name: "empty contributions retain explicit empty contexts",
|
||||
evidence: []LaneEvidence{{LaneID: "npcs"}},
|
||||
wantUnits: [][]int{},
|
||||
wantRefs: [][]EvidenceRef{},
|
||||
},
|
||||
{
|
||||
name: "largest window clips without overflow",
|
||||
window: math.MaxInt,
|
||||
evidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(30, 30)}}},
|
||||
wantUnits: [][]int{{10, 3, 30, 7, 50}},
|
||||
wantRefs: [][]EvidenceRef{{{LaneID: "npcs", SourceRef: ref(30, 30)}}},
|
||||
},
|
||||
{name: "zero window", refs: []source.SourceRef{ref(3, 3)}, wantIDs: []int{3}},
|
||||
{name: "multi unit citation includes complete range", refs: []source.SourceRef{ref(3, 7)}, wantIDs: []int{3, 30, 7}},
|
||||
{name: "non monotonic IDs use document positions", window: 1, refs: []source.SourceRef{ref(3, 3)}, wantIDs: []int{10, 3, 30}},
|
||||
{name: "boundary clamping", window: 1, refs: []source.SourceRef{ref(10, 10), ref(50, 50)}, wantIDs: []int{10, 3, 7, 50}},
|
||||
{name: "overlapping and adjacent windows merge", window: 1, refs: []source.SourceRef{ref(3, 3), ref(30, 30), ref(30, 30)}, wantIDs: []int{10, 3, 30, 7}},
|
||||
{name: "adjacent expanded ranges merge", window: 1, refs: []source.SourceRef{ref(10, 10), ref(7, 7)}, wantIDs: []int{10, 3, 30, 7, 50}},
|
||||
{name: "largest window clips without overflow", window: math.MaxInt, refs: []source.SourceRef{ref(30, 30)}, wantIDs: []int{10, 3, 30, 7, 50}},
|
||||
{name: "no references returns an initialized empty document", wantIDs: []int{}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
document := testDocument(t)
|
||||
got, err := Build(BuildRequest{Source: document, WindowUnits: test.window, SelectedLanes: []string{"spells", "npcs"}, LaneEvidence: test.evidence})
|
||||
got, err := Build(BuildRequest{Source: testDocument(t), WindowUnits: test.window, SourceRefs: test.refs})
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
if want := []string{"npcs", "spells"}; !reflect.DeepEqual(got.SelectedLanes, want) {
|
||||
t.Fatalf("SelectedLanes = %#v, want %#v", got.SelectedLanes, want)
|
||||
if got == nil {
|
||||
t.Fatal("Build() returned a nil document")
|
||||
}
|
||||
if got.WindowUnits != test.window || got.SourceID != document.ID || got.SourceDigest != document.Digest {
|
||||
t.Fatalf("Build() identity = %#v, want source and window identity", got)
|
||||
}
|
||||
if actual := contextUnitIDs(got.Contexts); !reflect.DeepEqual(actual, test.wantUnits) {
|
||||
t.Fatalf("context unit ids = %#v, want %#v", actual, test.wantUnits)
|
||||
}
|
||||
if actual := contextEvidenceRefs(got.Contexts); !reflect.DeepEqual(actual, test.wantRefs) {
|
||||
t.Fatalf("context evidence refs = %#v, want %#v", actual, test.wantRefs)
|
||||
if actual := unitIDs(got); !reflect.DeepEqual(actual, test.wantIDs) {
|
||||
t.Fatalf("unit IDs = %#v, want %#v", actual, test.wantIDs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIsStableAndOwnsSourceAndInputs(t *testing.T) {
|
||||
func TestBuildCopiesSelectedUnitsAndMetadata(t *testing.T) {
|
||||
document := testDocument(t)
|
||||
refs := []source.SourceRef{ref(30, 30), ref(3, 3)}
|
||||
request := BuildRequest{
|
||||
Source: document,
|
||||
WindowUnits: 1,
|
||||
SelectedLanes: []string{"spells", "npcs"},
|
||||
LaneEvidence: []LaneEvidence{{LaneID: "spells", SourceRefs: refs}, {LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}},
|
||||
}
|
||||
first, err := Build(request)
|
||||
first, err := Build(BuildRequest{Source: document, WindowUnits: 1, SourceRefs: []source.SourceRef{ref(3, 3)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondRequest := request
|
||||
secondRequest.LaneEvidence = []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}, {LaneID: "spells", SourceRefs: []source.SourceRef{ref(3, 3), ref(30, 30)}}}
|
||||
second, err := Build(secondRequest)
|
||||
second, err := Build(BuildRequest{Source: document, WindowUnits: 1, SourceRefs: []source.SourceRef{ref(3, 3)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(first, second) {
|
||||
t.Fatalf("Build() order differs:\nfirst: %#v\nsecond: %#v", first, second)
|
||||
if !reflect.DeepEqual(first[0], document.Units[0]) {
|
||||
t.Fatalf("first unit = %#v, want unchanged source unit %#v", first[0], document.Units[0])
|
||||
}
|
||||
first.SelectedLanes[0] = "changed"
|
||||
first.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] = "changed"
|
||||
first[0].Metadata["nested"].(map[string]any)["value"] = "changed"
|
||||
if document.Units[0].Metadata["nested"].(map[string]any)["value"] != "original" {
|
||||
t.Fatal("Build() returned metadata aliases to source document")
|
||||
t.Fatal("Build() returned metadata aliases to the source document")
|
||||
}
|
||||
document.Units[0].Metadata["nested"].(map[string]any)["value"] = "later"
|
||||
if second.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] != "original" {
|
||||
t.Fatal("Build() retained metadata aliases to source document")
|
||||
}
|
||||
refs[0].StartUnitID = 999
|
||||
if !containsEvidenceRef(second.Contexts[0].EvidenceRefs, ref(30, 30)) {
|
||||
t.Fatal("Build() retained source-reference input aliases")
|
||||
if second[0].Metadata["nested"].(map[string]any)["value"] != "original" {
|
||||
t.Fatal("Build() retained metadata aliases to the source document")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,18 +72,11 @@ func TestBuildRejectsInvalidInputs(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{name: "negative window", mutate: func(request *BuildRequest) { request.WindowUnits = -1 }, want: "window_units"},
|
||||
{name: "blank selected lane", mutate: func(request *BuildRequest) { request.SelectedLanes = []string{" "} }, want: "selected_lanes"},
|
||||
{name: "duplicate selected lane", mutate: func(request *BuildRequest) { request.SelectedLanes = []string{"npcs", " npcs "} }, want: "duplicated"},
|
||||
{name: "unselected contribution", mutate: func(request *BuildRequest) {
|
||||
request.LaneEvidence = []LaneEvidence{{LaneID: "other", SourceRefs: []source.SourceRef{ref(3, 3)}}}
|
||||
}, want: "not selected"},
|
||||
{name: "source digest mismatch", mutate: func(request *BuildRequest) { request.Source.Digest = "sha256:" + strings.Repeat("0", 64) }, want: "does not match"},
|
||||
{name: "invalid reference", mutate: func(request *BuildRequest) {
|
||||
request.LaneEvidence = []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(99, 99)}}}
|
||||
}, want: "source reference[0]"},
|
||||
{name: "invalid reference", mutate: func(request *BuildRequest) { request.SourceRefs = []source.SourceRef{ref(99, 99)} }, want: "source reference[0]"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
request := BuildRequest{Source: testDocument(t), SelectedLanes: []string{"npcs"}, LaneEvidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}}}
|
||||
request := BuildRequest{Source: testDocument(t), SourceRefs: []source.SourceRef{ref(3, 3)}}
|
||||
test.mutate(&request)
|
||||
if _, err := Build(request); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Build() error = %v, want %q", err, test.want)
|
||||
@@ -162,7 +85,7 @@ func TestBuildRejectsInvalidInputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRoundTripsCompactFixtureAndOwnsDecodedValues(t *testing.T) {
|
||||
func TestCodecRoundTripsFixtureAndOwnsValues(t *testing.T) {
|
||||
fixture, err := os.ReadFile("testdata/source_evidence_context.v1.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -179,15 +102,16 @@ func TestCodecRoundTripsCompactFixtureAndOwnsDecodedValues(t *testing.T) {
|
||||
if !bytes.Equal(encoded, bytes.TrimSpace(fixture)) {
|
||||
t.Fatalf("fixture does not use canonical encoding\nwant: %s\n got: %s", fixture, encoded)
|
||||
}
|
||||
value.Contexts[0].Units[0].Text = "changed"
|
||||
decoded, err := codec.Decode(fixture)
|
||||
value[0].Text = "changed"
|
||||
decoded, err := codec.Decode(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Contexts[0].Units[0].Text != "The party meets Rowan." {
|
||||
t.Fatal("Decode() reused mutable document storage")
|
||||
if decoded[0].Text != "The party meets Rowan." {
|
||||
t.Fatal("Encode() retained mutable document storage")
|
||||
}
|
||||
built, err := Build(BuildRequest{Source: testDocument(t), WindowUnits: 1, SelectedLanes: []string{"npcs"}, LaneEvidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}}})
|
||||
|
||||
built, err := Build(BuildRequest{Source: testDocument(t), WindowUnits: 1, SourceRefs: []source.SourceRef{ref(3, 3)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -203,82 +127,52 @@ func TestCodecRoundTripsCompactFixtureAndOwnsDecodedValues(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] = "changed"
|
||||
if second.Contexts[0].Units[0].Metadata["nested"].(map[string]any)["value"] != "original" {
|
||||
first[0].Metadata["nested"].(map[string]any)["value"] = "changed"
|
||||
if second[0].Metadata["nested"].(map[string]any)["value"] != "original" {
|
||||
t.Fatal("Decode() returned metadata aliases")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRejectsInvalidDurableBoundaries(t *testing.T) {
|
||||
value, err := Build(BuildRequest{Source: testDocument(t), SelectedLanes: []string{"npcs"}, LaneEvidence: []LaneEvidence{{LaneID: "npcs", SourceRefs: []source.SourceRef{ref(3, 3)}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
func TestCodecRejectsInvalidDurablePayloads(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*Document)
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{name: "unsorted lanes", mutate: func(value *Document) { value.SelectedLanes = []string{"z", "a"} }},
|
||||
{name: "context range mismatch", mutate: func(value *Document) { value.Contexts[0].ContextRef.EndUnitID = 999 }},
|
||||
{name: "mismatched evidence source", mutate: func(value *Document) { value.Contexts[0].EvidenceRefs[0].SourceRef.SourceID = "other" }},
|
||||
{name: "invalid evidence range", mutate: func(value *Document) {
|
||||
value.Contexts[0].EvidenceRefs[0].SourceRef.StartUnitID = 10
|
||||
}},
|
||||
{name: "duplicate context unit", mutate: func(value *Document) { value.Contexts = append(value.Contexts, value.Contexts[0]) }},
|
||||
{name: "null", content: "null"},
|
||||
{name: "wrapper object", content: `{"units":[]}`},
|
||||
{name: "missing required unit field", content: `[{"id":1,"kind":"segment","ref":{"source_id":"session","start_unit_id":1,"end_unit_id":1}}]`},
|
||||
{name: "unknown unit field", content: `[{"id":1,"kind":"segment","text":"text","ref":{"source_id":"session","start_unit_id":1,"end_unit_id":1},"unknown":true}]`},
|
||||
{name: "unknown reference field", content: `[{"id":1,"kind":"segment","text":"text","ref":{"source_id":"session","start_unit_id":1,"end_unit_id":1,"unknown":true}}]`},
|
||||
{name: "invalid self reference", content: `[{"id":1,"kind":"segment","text":"text","ref":{"source_id":"session","start_unit_id":1,"end_unit_id":2}}]`},
|
||||
{name: "mixed source documents", content: `[{"id":1,"kind":"segment","text":"one","ref":{"source_id":"session-one","start_unit_id":1,"end_unit_id":1}},{"id":2,"kind":"segment","text":"two","ref":{"source_id":"session-two","start_unit_id":2,"end_unit_id":2}}]`},
|
||||
{name: "duplicate units", content: `[{"id":1,"kind":"segment","text":"one","ref":{"source_id":"session","start_unit_id":1,"end_unit_id":1}},{"id":1,"kind":"segment","text":"two","ref":{"source_id":"session","start_unit_id":1,"end_unit_id":1}}]`},
|
||||
{name: "multiple JSON values", content: `[] []`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate, err := clone(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
test.mutate(&candidate)
|
||||
if _, err := New().Encode(candidate); err == nil {
|
||||
t.Fatal("Encode() error = nil, want durable model rejection")
|
||||
}
|
||||
})
|
||||
}
|
||||
content, err := New().Encode(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(map[string]any)
|
||||
}{
|
||||
{name: "missing contexts", mutate: func(value map[string]any) { delete(value, "contexts") }},
|
||||
{name: "null contexts", mutate: func(value map[string]any) { value["contexts"] = nil }},
|
||||
{name: "unknown fixed field", mutate: func(value map[string]any) { value["unknown"] = true }},
|
||||
{name: "missing units", mutate: func(value map[string]any) { delete(contextObject(value, 0), "units") }},
|
||||
{name: "null evidence refs", mutate: func(value map[string]any) { contextObject(value, 0)["evidence_refs"] = nil }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
raw := decodeJSON(t, content)
|
||||
test.mutate(raw)
|
||||
mutated, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := New().Decode(mutated); err == nil {
|
||||
if _, err := New().Decode([]byte(test.content)); err == nil {
|
||||
t.Fatal("Decode() error = nil, want strict payload rejection")
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := New().Decode(append(content, []byte(" {}")...)); err == nil {
|
||||
t.Fatal("Decode() error = nil, want trailing JSON rejection")
|
||||
if _, err := New().Encode(nil); err == nil {
|
||||
t.Fatal("Encode(nil) error = nil, want array rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeUsesFixedArtifactIdentity(t *testing.T) {
|
||||
artifact, err := Serialize(BuildRequest{Source: testDocument(t), SelectedLanes: []string{"npcs"}})
|
||||
func TestSerializeUsesFixedArtifactIdentityAndEmptyArray(t *testing.T) {
|
||||
artifact, err := Serialize(BuildRequest{Source: testDocument(t)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if artifact.Kind != ArtifactKind || artifact.MediaType != MediaType || artifact.Schema.ID != SchemaID || artifact.Schema.Name != SchemaName || artifact.Schema.Version != SchemaVersion {
|
||||
t.Fatalf("Serialize() = %#v, want fixed artifact identity", artifact)
|
||||
}
|
||||
if string(artifact.Content) != "[]" {
|
||||
t.Fatalf("Serialize() content = %s, want []", artifact.Content)
|
||||
}
|
||||
decoded, err := New().Decode(artifact.Content)
|
||||
if err != nil || len(decoded.Contexts) != 0 || decoded.Contexts == nil {
|
||||
t.Fatalf("Decode(Serialize()) = %#v, %v; want explicit empty contexts", decoded, err)
|
||||
if err != nil || decoded == nil || len(decoded) != 0 {
|
||||
t.Fatalf("Decode(Serialize()) = %#v, %v; want explicit empty array", decoded, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,45 +200,10 @@ func ref(start, end int) source.SourceRef {
|
||||
return source.SourceRef{SourceID: "session", StartUnitID: start, EndUnitID: end}
|
||||
}
|
||||
|
||||
func contextUnitIDs(contexts []Context) [][]int {
|
||||
values := make([][]int, len(contexts))
|
||||
for index, context := range contexts {
|
||||
values[index] = make([]int, len(context.Units))
|
||||
for unitIndex, unit := range context.Units {
|
||||
values[index][unitIndex] = unit.ID
|
||||
}
|
||||
func unitIDs(units Document) []int {
|
||||
values := make([]int, len(units))
|
||||
for index, unit := range units {
|
||||
values[index] = unit.ID
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func contextEvidenceRefs(contexts []Context) [][]EvidenceRef {
|
||||
values := make([][]EvidenceRef, len(contexts))
|
||||
for index, context := range contexts {
|
||||
values[index] = append([]EvidenceRef(nil), context.EvidenceRefs...)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func containsEvidenceRef(values []EvidenceRef, want source.SourceRef) bool {
|
||||
for _, value := range values {
|
||||
if value.SourceRef == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, content []byte) map[string]any {
|
||||
t.Helper()
|
||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||
decoder.UseNumber()
|
||||
var value map[string]any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func contextObject(value map[string]any, index int) map[string]any {
|
||||
return value["contexts"].([]any)[index].(map[string]any)
|
||||
}
|
||||
|
||||
@@ -14,37 +14,12 @@ const (
|
||||
MediaType = "application/json"
|
||||
)
|
||||
|
||||
// Document is the durable union of direct evidence and surrounding source
|
||||
// context selected for one accepted source document.
|
||||
type Document struct {
|
||||
SourceID string `json:"source_id"`
|
||||
SourceDigest string `json:"source_digest"`
|
||||
WindowUnits int `json:"window_units"`
|
||||
SelectedLanes []string `json:"selected_lanes"`
|
||||
Contexts []Context `json:"contexts"`
|
||||
}
|
||||
// Document is the durable selected source-unit excerpt.
|
||||
type Document []source.SourceUnit
|
||||
|
||||
type Context struct {
|
||||
ContextRef source.SourceRef `json:"context_ref"`
|
||||
EvidenceRefs []EvidenceRef `json:"evidence_refs"`
|
||||
Units []source.SourceUnit `json:"units"`
|
||||
}
|
||||
|
||||
type EvidenceRef struct {
|
||||
LaneID string `json:"lane_id"`
|
||||
SourceRef source.SourceRef `json:"source_ref"`
|
||||
}
|
||||
|
||||
// LaneEvidence attributes direct source references to one selected lane.
|
||||
type LaneEvidence struct {
|
||||
LaneID string `json:"lane_id"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
}
|
||||
|
||||
// BuildRequest supplies accepted source material and direct lane evidence.
|
||||
// BuildRequest supplies accepted source material and projected source references.
|
||||
type BuildRequest struct {
|
||||
Source *source.SourceDocument
|
||||
WindowUnits int
|
||||
SelectedLanes []string
|
||||
LaneEvidence []LaneEvidence
|
||||
Source *source.SourceDocument
|
||||
WindowUnits int
|
||||
SourceRefs []source.SourceRef
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/readonlyfs"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
)
|
||||
|
||||
@@ -223,7 +221,7 @@ func newAssetSource(fsys fs.FS, root string) (AssetSource, error) {
|
||||
}
|
||||
|
||||
func flattenAssetSources(sources []AssetSource) (fs.FS, error) {
|
||||
out := assetMapFS{}
|
||||
out := make(map[string][]byte)
|
||||
for _, source := range sources {
|
||||
if err := fs.WalkDir(source.FS, source.Root, func(name string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
@@ -247,13 +245,13 @@ func flattenAssetSources(sources []AssetSource) (fs.FS, error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out[rel] = append([]byte(nil), data...)
|
||||
out[rel] = data
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("walk asset root %s: %w", source.Root, err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return readonlyfs.New(out)
|
||||
}
|
||||
|
||||
func cleanAssetRoot(root string) (string, error) {
|
||||
@@ -275,156 +273,3 @@ func cleanAssetPath(name string) (string, error) {
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
type assetMapFS map[string][]byte
|
||||
|
||||
func (m assetMapFS) Open(name string) (fs.File, error) {
|
||||
cleaned, err := cleanOpenPath(name)
|
||||
if err != nil {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
|
||||
}
|
||||
if data, ok := m[cleaned]; ok {
|
||||
return &assetFile{
|
||||
reader: bytes.NewReader(data),
|
||||
info: assetFileInfo{name: path.Base(cleaned), size: int64(len(data))},
|
||||
}, nil
|
||||
}
|
||||
entries := m.dirEntries(cleaned)
|
||||
if entries != nil {
|
||||
return &assetDir{name: path.Base(cleaned), entries: entries}, nil
|
||||
}
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
|
||||
func (m assetMapFS) ReadFile(name string) ([]byte, error) {
|
||||
cleaned, err := cleanOpenPath(name)
|
||||
if err != nil {
|
||||
return nil, &fs.PathError{Op: "readfile", Path: name, Err: err}
|
||||
}
|
||||
data, ok := m[cleaned]
|
||||
if !ok {
|
||||
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
|
||||
func (m assetMapFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
cleaned, err := cleanOpenPath(name)
|
||||
if err != nil {
|
||||
return nil, &fs.PathError{Op: "readdir", Path: name, Err: err}
|
||||
}
|
||||
entries := m.dirEntries(cleaned)
|
||||
if entries == nil {
|
||||
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (m assetMapFS) dirEntries(dir string) []fs.DirEntry {
|
||||
children := map[string]assetDirEntry{}
|
||||
prefix := ""
|
||||
if dir != "." {
|
||||
prefix = dir + "/"
|
||||
}
|
||||
for name, data := range m {
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimPrefix(name, prefix)
|
||||
if rest == "" {
|
||||
continue
|
||||
}
|
||||
childName, _, hasSlash := strings.Cut(rest, "/")
|
||||
entry := assetDirEntry{name: childName, dir: hasSlash}
|
||||
if !hasSlash {
|
||||
entry.size = int64(len(data))
|
||||
}
|
||||
children[childName] = entry
|
||||
}
|
||||
if len(children) == 0 {
|
||||
if dir == "." {
|
||||
return []fs.DirEntry{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(children))
|
||||
for name := range children {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
entries := make([]fs.DirEntry, 0, len(names))
|
||||
for _, name := range names {
|
||||
entries = append(entries, children[name])
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func cleanOpenPath(name string) (string, error) {
|
||||
if name == "." {
|
||||
return ".", nil
|
||||
}
|
||||
return cleanAssetPath(name)
|
||||
}
|
||||
|
||||
type assetFile struct {
|
||||
reader *bytes.Reader
|
||||
info assetFileInfo
|
||||
}
|
||||
|
||||
func (f *assetFile) Stat() (fs.FileInfo, error) { return f.info, nil }
|
||||
func (f *assetFile) Read(p []byte) (int, error) { return f.reader.Read(p) }
|
||||
func (f *assetFile) Close() error { return nil }
|
||||
|
||||
type assetDir struct {
|
||||
name string
|
||||
offset int
|
||||
entries []fs.DirEntry
|
||||
}
|
||||
|
||||
func (d *assetDir) Stat() (fs.FileInfo, error) { return assetFileInfo{name: d.name, dir: true}, nil }
|
||||
func (d *assetDir) Read([]byte) (int, error) { return 0, fmt.Errorf("cannot read directory") }
|
||||
func (d *assetDir) Close() error { return nil }
|
||||
func (d *assetDir) ReadDir(n int) ([]fs.DirEntry, error) {
|
||||
if d.offset >= len(d.entries) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
end := len(d.entries)
|
||||
if n > 0 && d.offset+n < end {
|
||||
end = d.offset + n
|
||||
}
|
||||
out := append([]fs.DirEntry(nil), d.entries[d.offset:end]...)
|
||||
d.offset = end
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type assetDirEntry struct {
|
||||
name string
|
||||
dir bool
|
||||
size int64
|
||||
}
|
||||
|
||||
func (e assetDirEntry) Name() string { return e.name }
|
||||
func (e assetDirEntry) IsDir() bool { return e.dir }
|
||||
func (e assetDirEntry) Type() fs.FileMode { return e.InfoMode().Type() }
|
||||
func (e assetDirEntry) Info() (fs.FileInfo, error) {
|
||||
return assetFileInfo{name: e.name, dir: e.dir, size: e.size}, nil
|
||||
}
|
||||
func (e assetDirEntry) InfoMode() fs.FileMode {
|
||||
if e.dir {
|
||||
return fs.ModeDir | 0o555
|
||||
}
|
||||
return 0o444
|
||||
}
|
||||
|
||||
type assetFileInfo struct {
|
||||
name string
|
||||
dir bool
|
||||
size int64
|
||||
}
|
||||
|
||||
func (i assetFileInfo) Name() string { return i.name }
|
||||
func (i assetFileInfo) Size() int64 { return i.size }
|
||||
func (i assetFileInfo) Mode() fs.FileMode { return assetDirEntry{dir: i.dir}.InfoMode() }
|
||||
func (i assetFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (i assetFileInfo) IsDir() bool { return i.dir }
|
||||
func (i assetFileInfo) Sys() any { return nil }
|
||||
|
||||
@@ -142,7 +142,7 @@ func (c *PromptKitClient) CompleteStructured(ctx context.Context, req contracts.
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return contracts.StructuredCompletionResponse{}, ctxErr
|
||||
}
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("prepare PromptKit prompt %q: %w", promptID, redactPromptKitError(err))
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("prepare PromptKit prompt %q: %v", promptID, redactPromptKitError(err))
|
||||
}
|
||||
defer prepared.Discard()
|
||||
preparedDetails := prepared.Details()
|
||||
@@ -169,7 +169,7 @@ func (c *PromptKitClient) CompleteStructured(ctx context.Context, req contracts.
|
||||
redactPromptKitError(err),
|
||||
)
|
||||
}
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("run PromptKit prompt %q: %w", promptID, redactPromptKitError(err))
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("run PromptKit prompt %q: %v", promptID, redactPromptKitError(err))
|
||||
}
|
||||
if result == nil {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("run PromptKit prompt %q: %w: empty result", promptID, contracts.ErrInvalidStructuredOutput)
|
||||
@@ -427,17 +427,13 @@ func redactPromptKitError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return redactedProviderError{err: err}
|
||||
return sanitizedProviderDiagnostic{message: bearerTokenPattern.ReplaceAllString(err.Error(), "Bearer "+secretReplacement)}
|
||||
}
|
||||
|
||||
type redactedProviderError struct {
|
||||
err error
|
||||
type sanitizedProviderDiagnostic struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (e redactedProviderError) Error() string {
|
||||
return bearerTokenPattern.ReplaceAllString(e.err.Error(), "Bearer "+secretReplacement)
|
||||
}
|
||||
|
||||
func (e redactedProviderError) Unwrap() error {
|
||||
return e.err
|
||||
func (e sanitizedProviderDiagnostic) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
@@ -741,6 +741,11 @@ model: local-model
|
||||
!strings.Contains(err.Error(), promptkit.BackendLocal) {
|
||||
t.Fatalf("CompleteStructured() without registration error = %v, want preparation failure with local backend context", err)
|
||||
}
|
||||
for _, sentinel := range []error{promptkit.ErrProfileLoad, promptkit.ErrInvalidRequest, promptkit.ErrPromptNotFound} {
|
||||
if errors.Is(err, sentinel) {
|
||||
t.Fatalf("preparation failure exposes PromptKit sentinel %v: %v", sentinel, err)
|
||||
}
|
||||
}
|
||||
if providerCalls.Load() != 1 {
|
||||
t.Fatalf("provider calls after missing-registration failure = %d, want 1", providerCalls.Load())
|
||||
}
|
||||
@@ -814,7 +819,8 @@ func TestPromptKitClientDecodeFailureReturnsRawResponse(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPromptKitClientProviderFailureIncludesContextAndRedactsBearerToken(t *testing.T) {
|
||||
client := newTestPromptKitClient(t, &fakePromptKitLLM{err: errors.New("provider failed with Bearer secret-token")})
|
||||
providerErr := &credentialBearingProviderError{}
|
||||
client := newTestPromptKitClient(t, &fakePromptKitLLM{err: providerErr})
|
||||
|
||||
var out map[string]any
|
||||
resp, err := client.CompleteStructured(context.Background(), contracts.StructuredCompletionRequest{
|
||||
@@ -836,6 +842,21 @@ func TestPromptKitClientProviderFailureIncludesContextAndRedactsBearerToken(t *t
|
||||
if strings.Contains(err.Error(), "secret-token") || !strings.Contains(err.Error(), "Bearer [REDACTED]") {
|
||||
t.Fatalf("error = %q, want redacted bearer token", err.Error())
|
||||
}
|
||||
for current := err; current != nil; current = errors.Unwrap(current) {
|
||||
if strings.Contains(current.Error(), "secret-token") {
|
||||
t.Fatalf("error chain exposes credential: %v", err)
|
||||
}
|
||||
}
|
||||
if errors.Unwrap(err) != nil {
|
||||
t.Fatalf("provider failure must not expose a wrapped diagnostic: %v", err)
|
||||
}
|
||||
var recoveredProviderErr *credentialBearingProviderError
|
||||
if errors.As(err, &recoveredProviderErr) {
|
||||
t.Fatalf("provider error escaped the adapter: %v", err)
|
||||
}
|
||||
if errors.Is(err, promptkit.ErrLLMGenerate) {
|
||||
t.Fatalf("provider failure exposes PromptKit generation sentinel: %v", err)
|
||||
}
|
||||
if resp.Debug != nil {
|
||||
t.Fatalf("debug material = %#v, want none for provider failure without result", resp.Debug)
|
||||
}
|
||||
@@ -1214,6 +1235,12 @@ type fakePromptKitLLM struct {
|
||||
maxInFlight int32
|
||||
}
|
||||
|
||||
type credentialBearingProviderError struct{}
|
||||
|
||||
func (*credentialBearingProviderError) Error() string {
|
||||
return "provider failed with Bearer secret-token"
|
||||
}
|
||||
|
||||
func (f *fakePromptKitLLM) Generate(ctx context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||
f.mu.Lock()
|
||||
f.last = req
|
||||
|
||||
@@ -83,6 +83,9 @@ func (s *Scheduler) Run(ctx context.Context, fn func(context.Context) error) err
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,79 @@ func TestSchedulerRunReleasesPermitAfterError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type cancelAfterAdmissionContext struct {
|
||||
done chan struct{}
|
||||
checks atomic.Int32
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newCancelAfterAdmissionContext() *cancelAfterAdmissionContext {
|
||||
return &cancelAfterAdmissionContext{done: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (*cancelAfterAdmissionContext) Deadline() (time.Time, bool) { return time.Time{}, false }
|
||||
|
||||
func (c *cancelAfterAdmissionContext) Done() <-chan struct{} { return c.done }
|
||||
|
||||
func (c *cancelAfterAdmissionContext) Err() error {
|
||||
if c.checks.Add(1) == 1 {
|
||||
return nil
|
||||
}
|
||||
c.once.Do(func() { close(c.done) })
|
||||
return context.Canceled
|
||||
}
|
||||
|
||||
func (*cancelAfterAdmissionContext) Value(any) any { return nil }
|
||||
|
||||
func TestSchedulerDoesNotDispatchCanceledAdmissionAndReleasesNextWaiter(t *testing.T) {
|
||||
s, err := NewScheduler(1)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScheduler: %v", err)
|
||||
}
|
||||
hold, err := s.Acquire(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Acquire: %v", err)
|
||||
}
|
||||
defer hold()
|
||||
|
||||
ctx := newCancelAfterAdmissionContext()
|
||||
var canceledCalls atomic.Int32
|
||||
canceledErr := make(chan error, 1)
|
||||
go func() {
|
||||
canceledErr <- s.Run(ctx, func(context.Context) error {
|
||||
canceledCalls.Add(1)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
waitForQueueDepth(t, s, 1)
|
||||
|
||||
nextStarted := make(chan struct{})
|
||||
nextErr := make(chan error, 1)
|
||||
go func() {
|
||||
nextErr <- s.Run(context.Background(), func(context.Context) error {
|
||||
close(nextStarted)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
waitForQueueDepth(t, s, 2)
|
||||
|
||||
hold()
|
||||
if err := <-canceledErr; !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run() error = %v, want context.Canceled", err)
|
||||
}
|
||||
if got := canceledCalls.Load(); got != 0 {
|
||||
t.Fatalf("canceled callback calls = %d, want none", got)
|
||||
}
|
||||
select {
|
||||
case <-nextStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timed out waiting for next FIFO waiter")
|
||||
}
|
||||
if err := <-nextErr; err != nil {
|
||||
t.Fatalf("next Run() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForAtomicAtLeast(t *testing.T, value *int32, want int32) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
|
||||
@@ -69,6 +69,7 @@ type artifactCodecEntry struct {
|
||||
encodeCandidate func(any) ([]byte, error)
|
||||
metadata func(any) (map[string]any, error)
|
||||
decode func([]byte) (any, error)
|
||||
decodeCandidate func([]byte) (any, error)
|
||||
}
|
||||
|
||||
func NewArtifactCodecRegistry() *ArtifactCodecRegistry {
|
||||
@@ -77,7 +78,7 @@ func NewArtifactCodecRegistry() *ArtifactCodecRegistry {
|
||||
|
||||
// RegisterArtifactCodec registers one codec for T. The concrete type is kept
|
||||
// private and checked at every erased encode boundary.
|
||||
func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contracts.ArtifactCodec[T]) error {
|
||||
func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contracts.CandidateArtifactCodec[T]) error {
|
||||
if registry == nil {
|
||||
return fmt.Errorf("artifact codec registry must not be nil")
|
||||
}
|
||||
@@ -119,6 +120,13 @@ func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contrac
|
||||
}
|
||||
return decoded, nil
|
||||
},
|
||||
decodeCandidate: func(content []byte) (any, error) {
|
||||
decoded, err := codec.DecodeCandidate(append([]byte(nil), content...))
|
||||
if err != nil {
|
||||
return nil, &ArtifactCodecOperationError{Operation: "decode candidate", Kind: spec.Kind, Err: err}
|
||||
}
|
||||
return decoded, nil
|
||||
},
|
||||
}
|
||||
entry.encodeCandidate = func(value any) ([]byte, error) {
|
||||
typed, err := exactTypedValue[T]("encode candidate artifact", value)
|
||||
|
||||
@@ -14,7 +14,13 @@ import (
|
||||
)
|
||||
|
||||
type codecNotes struct {
|
||||
Items []string `json:"items"`
|
||||
Items []string `json:"items"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Details *codecNoteDetails `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type codecNoteDetails struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type codecScore struct {
|
||||
@@ -24,12 +30,13 @@ type codecScore struct {
|
||||
type codecNotesAlias codecNotes
|
||||
|
||||
type testArtifactCodec[T any] struct {
|
||||
kind contracts.ArtifactKind
|
||||
schema contracts.ArtifactSchema
|
||||
mediaType string
|
||||
encodeFunc func(T) ([]byte, error)
|
||||
candidateFunc func(T) ([]byte, error)
|
||||
decodeFunc func([]byte) (T, error)
|
||||
kind contracts.ArtifactKind
|
||||
schema contracts.ArtifactSchema
|
||||
mediaType string
|
||||
encodeFunc func(T) ([]byte, error)
|
||||
candidateFunc func(T) ([]byte, error)
|
||||
decodeFunc func([]byte) (T, error)
|
||||
candidateDecodeFunc func([]byte) (T, error)
|
||||
}
|
||||
|
||||
func (c testArtifactCodec[T]) Kind() contracts.ArtifactKind { return c.kind }
|
||||
@@ -43,8 +50,15 @@ func (c testArtifactCodec[T]) EncodeCandidate(value T) ([]byte, error) {
|
||||
}
|
||||
func (c testArtifactCodec[T]) Encode(value T) ([]byte, error) { return c.encodeFunc(value) }
|
||||
func (c testArtifactCodec[T]) Decode(content []byte) (T, error) { return c.decodeFunc(content) }
|
||||
func (c testArtifactCodec[T]) DecodeCandidate(content []byte) (T, error) {
|
||||
if c.candidateDecodeFunc != nil {
|
||||
return c.candidateDecodeFunc(content)
|
||||
}
|
||||
return c.decodeFunc(content)
|
||||
}
|
||||
|
||||
var _ contracts.ArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{}
|
||||
var _ contracts.CandidateArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{}
|
||||
|
||||
func TestArtifactCodecRegistryStoresHeterogeneousExactTypes(t *testing.T) {
|
||||
registry := NewArtifactCodecRegistry()
|
||||
@@ -138,6 +152,44 @@ func TestArtifactCodecRegistryKeepsCandidateAndFinalEncodingDistinct(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryKeepsCandidateAndFinalDecodingDistinct(t *testing.T) {
|
||||
candidateCalls, finalCalls := 0, 0
|
||||
codec := notesCodec()
|
||||
codec.candidateDecodeFunc = func([]byte) (codecNotes, error) {
|
||||
candidateCalls++
|
||||
return codecNotes{Items: []string{"candidate"}}, nil
|
||||
}
|
||||
codec.decodeFunc = func([]byte) (codecNotes, error) {
|
||||
finalCalls++
|
||||
return codecNotes{Items: []string{"final"}}, nil
|
||||
}
|
||||
registry := NewArtifactCodecRegistry()
|
||||
if err := RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
|
||||
}
|
||||
entry, _, err := registry.entry(codec.kind)
|
||||
if err != nil {
|
||||
t.Fatalf("entry() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
candidate, err := entry.decodeCandidate([]byte(`{"items":["one"]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("candidate decode error = %v", err)
|
||||
}
|
||||
candidateWant := codecNotes{Items: []string{"candidate"}}
|
||||
if !reflect.DeepEqual(candidate, candidateWant) || candidateCalls != 1 || finalCalls != 0 {
|
||||
t.Fatalf("candidate decode = %#v, calls = candidate %d, final %d", candidate, candidateCalls, finalCalls)
|
||||
}
|
||||
decoded, err := entry.decode([]byte(`{"items":["one"]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("final decode error = %v", err)
|
||||
}
|
||||
finalWant := codecNotes{Items: []string{"final"}}
|
||||
if !reflect.DeepEqual(decoded, finalWant) || candidateCalls != 1 || finalCalls != 1 {
|
||||
t.Fatalf("final decode = %#v, calls = candidate %d, final %d", decoded, candidateCalls, finalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryStoresValidatedSchemaMetadata(t *testing.T) {
|
||||
registry := NewArtifactCodecRegistry()
|
||||
codec := notesCodec()
|
||||
@@ -164,7 +216,7 @@ func TestArtifactCodecRegistryStoresValidatedSchemaMetadata(t *testing.T) {
|
||||
|
||||
spec.Schema.JSONSchema[0] = '['
|
||||
again, _ := registry.Spec("test/notes")
|
||||
if string(again.Schema.JSONSchema) != `{"additionalProperties":false,"properties":{"items":{"items":{"type":"string"},"type":"array"}},"required":["items"],"type":"object"}` {
|
||||
if string(again.Schema.JSONSchema) != `{"additionalProperties":false,"properties":{"details":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"},"items":{"items":{"type":"string"},"type":"array"},"labels":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["items"],"type":"object"}` {
|
||||
t.Fatalf("stored JSON Schema changed through Spec result: %q", again.Schema.JSONSchema)
|
||||
}
|
||||
}
|
||||
@@ -267,6 +319,28 @@ func TestArtifactCodecRegistryWrapsEncodeFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryWrapsCandidateDecodeFailure(t *testing.T) {
|
||||
cause := errors.New("cannot decode candidate notes")
|
||||
codec := notesCodec()
|
||||
codec.candidateDecodeFunc = func([]byte) (codecNotes, error) {
|
||||
return codecNotes{}, cause
|
||||
}
|
||||
registry := NewArtifactCodecRegistry()
|
||||
if err := RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
|
||||
}
|
||||
entry, _, err := registry.entry(codec.kind)
|
||||
if err != nil {
|
||||
t.Fatalf("entry() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
_, err = entry.decodeCandidate([]byte(`{"items":["one"]}`))
|
||||
var operationErr *ArtifactCodecOperationError
|
||||
if !errors.As(err, &operationErr) || operationErr.Operation != "decode candidate" || !errors.Is(err, cause) {
|
||||
t.Fatalf("candidate decode error = %T %v, want typed wrapping error", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
|
||||
shared := []byte(`{"items":["one"]}`)
|
||||
codec := notesCodec()
|
||||
@@ -275,6 +349,10 @@ func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
|
||||
content[0] = '['
|
||||
return codecNotes{Items: []string{"one"}}, nil
|
||||
}
|
||||
codec.candidateDecodeFunc = func(content []byte) (codecNotes, error) {
|
||||
content[0] = '['
|
||||
return codecNotes{Items: []string{"candidate"}}, nil
|
||||
}
|
||||
registry := NewArtifactCodecRegistry()
|
||||
if err := RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
|
||||
@@ -295,6 +373,18 @@ func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
|
||||
if !bytes.Equal(artifact.Content, before) {
|
||||
t.Fatalf("serialized content changed during decode: %q", artifact.Content)
|
||||
}
|
||||
candidateContent := []byte(`{"items":["candidate"]}`)
|
||||
candidateBefore := append([]byte(nil), candidateContent...)
|
||||
entry, _, err := registry.entry("test/notes")
|
||||
if err != nil {
|
||||
t.Fatalf("entry() error = %v, want nil", err)
|
||||
}
|
||||
if _, err := entry.decodeCandidate(candidateContent); err != nil {
|
||||
t.Fatalf("candidate decode error = %v, want nil", err)
|
||||
}
|
||||
if !bytes.Equal(candidateContent, candidateBefore) {
|
||||
t.Fatalf("candidate content changed during decode: %q", candidateContent)
|
||||
}
|
||||
}
|
||||
|
||||
func notesCodec() testArtifactCodec[codecNotes] {
|
||||
@@ -304,7 +394,7 @@ func notesCodec() testArtifactCodec[codecNotes] {
|
||||
ID: "notes.v1",
|
||||
Name: "notes",
|
||||
Version: "v1",
|
||||
JSONSchema: []byte(`{"additionalProperties":false,"properties":{"items":{"items":{"type":"string"},"type":"array"}},"required":["items"],"type":"object"}`),
|
||||
JSONSchema: []byte(`{"additionalProperties":false,"properties":{"details":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"},"items":{"items":{"type":"string"},"type":"array"},"labels":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["items"],"type":"object"}`),
|
||||
},
|
||||
mediaType: "application/json",
|
||||
encodeFunc: func(value codecNotes) ([]byte, error) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
@@ -34,32 +35,6 @@ func NoopDebugRecorder() DebugRecorder { return noopDebugRecorder{} }
|
||||
func (noopDebugRecorder) Enabled() bool { return false }
|
||||
func (noopDebugRecorder) WriteJSON(string, any) error { return nil }
|
||||
func (noopDebugRecorder) WriteBytes(string, []byte) error { return nil }
|
||||
func debugPathComponent(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "_"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '-' || r == '_' || r == '.':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteString(fmt.Sprintf("~%x", r))
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if out == "." || out == ".." || strings.Contains(out, "..") {
|
||||
return "_"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type debugTimedEnvelope struct {
|
||||
Stage string `json:"stage,omitempty"`
|
||||
@@ -242,7 +217,7 @@ func (client *debugLLMClient) CompleteStructured(ctx context.Context, req contra
|
||||
}
|
||||
|
||||
scopePrefix := cleanDebugPath(req.StageName)
|
||||
if scopePrefix == "_" {
|
||||
if req.StageName == "" {
|
||||
scopePrefix = "llm"
|
||||
}
|
||||
if scope := debugLLMScopeFromContext(ctx); scope != nil {
|
||||
@@ -310,7 +285,6 @@ func withDebugLLMScope(ctx context.Context, prefix string) (context.Context, *de
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
prefix = cleanDebugPath(prefix)
|
||||
scope := &debugLLMScope{
|
||||
prefix: prefix,
|
||||
parent: debugLLMScopeFromContext(ctx),
|
||||
@@ -322,7 +296,6 @@ func withIsolatedDebugLLMScope(ctx context.Context, prefix string) (context.Cont
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
prefix = cleanDebugPath(prefix)
|
||||
scope := &debugLLMScope{prefix: prefix}
|
||||
return context.WithValue(ctx, debugLLMScopeContextKey{}, scope), scope
|
||||
}
|
||||
@@ -362,15 +335,12 @@ func (scope *debugLLMScope) references() []debugLLMCallReference {
|
||||
}
|
||||
|
||||
func cleanDebugPath(value string) string {
|
||||
parts := strings.Split(path.Clean(strings.TrimSpace(value)), "/")
|
||||
parts := strings.Split(value, "/")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
out = append(out, debugPathComponent(part))
|
||||
out = append(out, fileio.EncodePathComponent(part))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return "_"
|
||||
}
|
||||
return path.Join(out...)
|
||||
return strings.Join(out, "/")
|
||||
}
|
||||
|
||||
func debugFirstNonEmptyString(values ...string) string {
|
||||
|
||||
@@ -1,13 +1,52 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func TestCleanDebugPathPreservesRawComponents(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{value: "", want: "%"},
|
||||
{value: ".", want: "%2E"},
|
||||
{value: "..", want: "%2E%2E"},
|
||||
{value: "a//b", want: "a/%/b"},
|
||||
{value: "/a/", want: "%/a/%"},
|
||||
{value: " a ", want: "%20a%20"},
|
||||
} {
|
||||
if got := cleanDebugPath(test.value); got != test.want {
|
||||
t.Errorf("cleanDebugPath(%q) = %q, want %q", test.value, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugLLMPathsKeepDotIdentitiesDistinct(t *testing.T) {
|
||||
recorder := newCapturedDebugRecorder()
|
||||
client := WithDebugLLMRecording(attemptDebugLLM{}, recorder)
|
||||
for _, test := range []struct {
|
||||
stageName string
|
||||
path string
|
||||
}{
|
||||
{stageName: ".", path: "%2E/response-0001.json"},
|
||||
{stageName: "..", path: "%2E%2E/response-0002.json"},
|
||||
} {
|
||||
if _, err := client.CompleteStructured(context.Background(), contracts.StructuredCompletionRequest{StageName: test.stageName}, nil); err != nil {
|
||||
t.Fatalf("CompleteStructured(%q): %v", test.stageName, err)
|
||||
}
|
||||
if !recorder.has(test.path) {
|
||||
t.Errorf("debug artifact %q was not written; names = %#v", test.path, recorder.names())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugSourceDocumentPreservesUnitReferences(t *testing.T) {
|
||||
doc := validSourceDocument()
|
||||
envelope := debugSourceDocumentEnvelope(doc)
|
||||
|
||||
@@ -20,7 +20,6 @@ type debugEvidenceContextSummary struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
SelectedLanes []string `json:"selected_lanes"`
|
||||
WindowUnits int `json:"window_units"`
|
||||
ContextCount int `json:"context_count"`
|
||||
UnitCount int `json:"unit_count"`
|
||||
SourceDigest string `json:"source_digest"`
|
||||
}
|
||||
@@ -49,10 +48,9 @@ func buildOutputEvidenceContext(prepared *PreparedPipeline, doc *source.SourceDo
|
||||
}
|
||||
|
||||
request := evidencecontext.BuildRequest{
|
||||
Source: doc,
|
||||
WindowUnits: prepared.evidencePlan.policy.WindowUnits,
|
||||
SelectedLanes: append([]string(nil), prepared.evidencePlan.policy.LaneIDs...),
|
||||
LaneEvidence: make([]evidencecontext.LaneEvidence, 0, len(prepared.evidencePlan.lanes)),
|
||||
Source: doc,
|
||||
WindowUnits: prepared.evidencePlan.policy.WindowUnits,
|
||||
SourceRefs: make([]source.SourceRef, 0),
|
||||
}
|
||||
for _, lane := range prepared.evidencePlan.lanes {
|
||||
output, ok := byLane[lane.laneID]
|
||||
@@ -73,10 +71,7 @@ func buildOutputEvidenceContext(prepared *PreparedPipeline, doc *source.SourceDo
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("evidence context output lane %q: accepted normalized artifact cannot be projected", lane.laneID)
|
||||
}
|
||||
request.LaneEvidence = append(request.LaneEvidence, evidencecontext.LaneEvidence{
|
||||
LaneID: lane.laneID,
|
||||
SourceRefs: append([]source.SourceRef(nil), references...),
|
||||
})
|
||||
request.SourceRefs = append(request.SourceRefs, references...)
|
||||
}
|
||||
|
||||
document, err := evidencecontext.Build(request)
|
||||
@@ -99,13 +94,10 @@ func buildOutputEvidenceContext(prepared *PreparedPipeline, doc *source.SourceDo
|
||||
SchemaID: artifact.Schema.ID,
|
||||
SchemaName: artifact.Schema.Name,
|
||||
SchemaVersion: artifact.Schema.Version,
|
||||
SelectedLanes: append([]string(nil), document.SelectedLanes...),
|
||||
WindowUnits: document.WindowUnits,
|
||||
ContextCount: len(document.Contexts),
|
||||
SourceDigest: document.SourceDigest,
|
||||
}
|
||||
for _, context := range document.Contexts {
|
||||
summary.UnitCount += len(context.Units)
|
||||
SelectedLanes: append([]string(nil), prepared.evidencePlan.policy.LaneIDs...),
|
||||
WindowUnits: prepared.evidencePlan.policy.WindowUnits,
|
||||
SourceDigest: doc.Digest,
|
||||
UnitCount: len(document),
|
||||
}
|
||||
return contracts.CloneSerializedArtifactPointer(artifact), &summary, nil
|
||||
}
|
||||
|
||||
@@ -97,14 +97,11 @@ func TestRunnerBuildsEvidenceContextFromSelectedNormalizedOutputs(t *testing.T)
|
||||
}
|
||||
|
||||
value := decodeCapturedEvidence(t, encoder)
|
||||
if !reflect.DeepEqual(value.SelectedLanes, []string{"alpha", "beta", "inactive"}) || len(value.Contexts) != 1 || len(value.Contexts[0].Units) != 3 {
|
||||
if actual := []int{value[0].ID, value[1].ID, value[2].ID}; !reflect.DeepEqual(actual, []int{1, 2, 3}) {
|
||||
t.Fatalf("evidence context = %#v, want selected union", value)
|
||||
}
|
||||
if got := value.Contexts[0].EvidenceRefs; len(got) != 2 || got[0].LaneID != "alpha" || got[1].LaneID != "beta" {
|
||||
t.Fatalf("evidence refs = %#v, want both selected lanes", got)
|
||||
}
|
||||
debugJSON := string(debug.json["output/evidence-context.json"])
|
||||
if strings.Contains(debugJSON, "text-1") || strings.Contains(debugJSON, "metadata") || !strings.Contains(debugJSON, `"artifact_kind":"source/evidence-context"`) || !strings.Contains(debugJSON, `"schema_id":"notarius.source.evidence_context"`) || !strings.Contains(debugJSON, `"context_count":1`) || !strings.Contains(debugJSON, `"unit_count":3`) {
|
||||
if strings.Contains(debugJSON, "text-1") || strings.Contains(debugJSON, "metadata") || !strings.Contains(debugJSON, `"artifact_kind":"source/evidence-context"`) || !strings.Contains(debugJSON, `"schema_id":"notarius.source.evidence_context"`) || strings.Contains(debugJSON, "context_count") || !strings.Contains(debugJSON, `"unit_count":3`) {
|
||||
t.Fatalf("evidence debug envelope = %s, want only allowlisted summary", debugJSON)
|
||||
}
|
||||
}
|
||||
@@ -134,7 +131,7 @@ func TestRunnerEvidenceContextOmitsAbsentAndRejectedLanes(t *testing.T) {
|
||||
t.Fatalf("rejections = %#v, want rejected lane unchanged", result.Rejected)
|
||||
}
|
||||
value := decodeCapturedEvidence(t, encoder)
|
||||
if len(value.Contexts) != 1 || len(value.Contexts[0].EvidenceRefs) != 1 || value.Contexts[0].EvidenceRefs[0].LaneID != "present" {
|
||||
if len(value) != 1 || value[0].ID != 1 {
|
||||
t.Fatalf("evidence context = %#v, want present lane only", value)
|
||||
}
|
||||
}
|
||||
@@ -232,7 +229,7 @@ func TestRunnerEvidenceContextRebuildsFromAcceptedCheckpoint(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
value := decodeCapturedEvidence(t, encoder)
|
||||
if len(value.Contexts) != 1 || value.Contexts[0].EvidenceRefs[0].LaneID != "notes" {
|
||||
if len(value) != 1 || value[0].ID != 1 {
|
||||
t.Fatalf("evidence context = %#v, want checkpointed normalized output", value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,16 @@ type referenceTargetKey struct {
|
||||
Stage ModuleStage
|
||||
}
|
||||
|
||||
type generatedOutputKey struct {
|
||||
stepID string
|
||||
laneID string
|
||||
}
|
||||
|
||||
type indexedGeneratedOutput struct {
|
||||
count int
|
||||
output contracts.SerializedOutput
|
||||
}
|
||||
|
||||
func keyForReferenceTarget(target ResolvedReferenceTarget) referenceTargetKey {
|
||||
return referenceTargetKey{StepID: target.StepID, LaneID: target.LaneID, Stage: target.Stage}
|
||||
}
|
||||
@@ -40,7 +50,8 @@ func buildStepReferenceSets(input RunInput, step PreparedPipelineStep, outputs [
|
||||
}
|
||||
sets := make(map[referenceTargetKey]contracts.ReferenceSet)
|
||||
var provenance []artifacts.ReferenceProvenance
|
||||
canonical := make(map[string]contracts.ReferenceItem)
|
||||
outputsByProducer := indexGeneratedOutputs(outputs)
|
||||
canonical := make(map[generatedOutputKey]contracts.ReferenceItem)
|
||||
for _, prepared := range step.lanes {
|
||||
lane := prepared.resolved
|
||||
for _, target := range []ResolvedReferenceTarget{lane.ExtractReferences, lane.MergeReferences, lane.NormalizeReferences} {
|
||||
@@ -51,7 +62,7 @@ func buildStepReferenceSets(input RunInput, step PreparedPipelineStep, outputs [
|
||||
continue
|
||||
}
|
||||
generated = true
|
||||
item, err := generatedReferenceItem(input, binding, outputs, canonical)
|
||||
item, err := generatedReferenceItem(input, binding, outputsByProducer, canonical)
|
||||
if err != nil {
|
||||
return nil, nil, contextualHandoffError(input, target, binding, err)
|
||||
}
|
||||
@@ -101,31 +112,43 @@ func contextualHandoffError(input RunInput, target ResolvedReferenceTarget, bind
|
||||
return fmt.Errorf("pipeline %q step %q lane %q %s generated dependency %q from %q/%q: %w", input.pipeline.ID, target.StepID, target.LaneID, target.Stage, binding.SlotName, binding.Artifact.Step, binding.Artifact.Lane, err)
|
||||
}
|
||||
|
||||
func generatedReferenceItem(input RunInput, binding ReferenceBinding, outputs []contracts.SerializedOutput, cache map[string]contracts.ReferenceItem) (contracts.ReferenceItem, error) {
|
||||
func generatedOutputKeyFor(stepID, laneID string) generatedOutputKey {
|
||||
return generatedOutputKey{stepID: strings.TrimSpace(stepID), laneID: strings.TrimSpace(laneID)}
|
||||
}
|
||||
|
||||
func indexGeneratedOutputs(outputs []contracts.SerializedOutput) map[generatedOutputKey]indexedGeneratedOutput {
|
||||
indexed := make(map[generatedOutputKey]indexedGeneratedOutput, len(outputs))
|
||||
for _, output := range outputs {
|
||||
key := generatedOutputKeyFor(output.StepID, output.LaneID)
|
||||
entry := indexed[key]
|
||||
entry.count++
|
||||
if entry.count == 1 {
|
||||
entry.output = output
|
||||
}
|
||||
indexed[key] = entry
|
||||
}
|
||||
return indexed
|
||||
}
|
||||
|
||||
func generatedReferenceItem(input RunInput, binding ReferenceBinding, outputsByProducer map[generatedOutputKey]indexedGeneratedOutput, cache map[generatedOutputKey]contracts.ReferenceItem) (contracts.ReferenceItem, error) {
|
||||
selector := binding.Artifact
|
||||
if selector == nil {
|
||||
return contracts.ReferenceItem{}, fmt.Errorf("generated reference selector must not be nil")
|
||||
}
|
||||
stepID := strings.TrimSpace(selector.Step)
|
||||
laneID := strings.TrimSpace(selector.Lane)
|
||||
cacheKey := stepID + "\x00" + laneID
|
||||
if item, ok := cache[cacheKey]; ok {
|
||||
producerKey := generatedOutputKeyFor(selector.Step, selector.Lane)
|
||||
if item, ok := cache[producerKey]; ok {
|
||||
item.SlotName = strings.TrimSpace(binding.SlotName)
|
||||
item.BindingSource = strings.TrimSpace(binding.BindingSource)
|
||||
return contracts.CloneReferenceItem(item), nil
|
||||
}
|
||||
matches := make([]contracts.SerializedOutput, 0, 1)
|
||||
for _, output := range outputs {
|
||||
if strings.TrimSpace(output.StepID) == stepID && strings.TrimSpace(output.LaneID) == laneID {
|
||||
matches = append(matches, output)
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
matched, ok := outputsByProducer[producerKey]
|
||||
if !ok {
|
||||
return contracts.ReferenceItem{}, fmt.Errorf("producer has no accepted normalized output")
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return contracts.ReferenceItem{}, fmt.Errorf("producer has %d accepted normalized outputs; exactly one is required", len(matches))
|
||||
if matched.count > 1 {
|
||||
return contracts.ReferenceItem{}, fmt.Errorf("producer has %d accepted normalized outputs; exactly one is required", matched.count)
|
||||
}
|
||||
stepID, laneID := producerKey.stepID, producerKey.laneID
|
||||
producer, ok := findResolvedLane(input.pipeline, stepID, laneID)
|
||||
if !ok {
|
||||
return contracts.ReferenceItem{}, fmt.Errorf("producer lane is not present in the resolved pipeline")
|
||||
@@ -133,7 +156,7 @@ func generatedReferenceItem(input RunInput, binding ReferenceBinding, outputs []
|
||||
if input.Prepared == nil || input.Prepared.artifactCodecs == nil {
|
||||
return contracts.ReferenceItem{}, fmt.Errorf("artifact codec registry is unavailable")
|
||||
}
|
||||
serialized := contracts.CloneSerializedArtifact(matches[0].Artifact)
|
||||
serialized := contracts.CloneSerializedArtifact(matched.output.Artifact)
|
||||
if serialized.Kind != producer.ArtifactKind {
|
||||
return contracts.ReferenceItem{}, fmt.Errorf("producer artifact kind %q does not match resolved kind %q", serialized.Kind, producer.ArtifactKind)
|
||||
}
|
||||
@@ -172,7 +195,7 @@ func generatedReferenceItem(input RunInput, binding ReferenceBinding, outputs []
|
||||
cacheItem := contracts.CloneReferenceItem(item)
|
||||
cacheItem.SlotName = ""
|
||||
cacheItem.BindingSource = ""
|
||||
cache[cacheKey] = cacheItem
|
||||
cache[producerKey] = cacheItem
|
||||
return item, nil
|
||||
}
|
||||
|
||||
@@ -243,6 +266,7 @@ type generatedReferenceFingerprintIdentity struct {
|
||||
SchemaDigest string `json:"schema_digest"`
|
||||
MediaType string `json:"media_type"`
|
||||
ContentDigest string `json:"content_digest"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
// generatedReferenceDependencies returns the canonical semantic dependency
|
||||
@@ -271,6 +295,7 @@ func generatedReferenceDependencies(set contracts.ReferenceSet) []CheckpointFing
|
||||
SchemaDigest: contracts.DigestArtifactSchema(item.ArtifactSchema),
|
||||
MediaType: item.MediaType,
|
||||
ContentDigest: item.Digest,
|
||||
SizeBytes: item.SizeBytes,
|
||||
}
|
||||
encoded, err := json.Marshal(identity)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package pipeline
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -106,6 +107,29 @@ func TestBuildStepReferenceSetsCanonicalizesAndClonesFanout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStepReferenceSetsIndexesManyOutputsForFanout(t *testing.T) {
|
||||
input, step, producerOutput := handoffFixture(t, codecNotes{Items: []string{"first"}})
|
||||
outputs := make([]contracts.SerializedOutput, 0, 65)
|
||||
for index := range 64 {
|
||||
outputs = append(outputs, contracts.SerializedOutput{StepID: fmt.Sprintf("unrelated-step-%d", index), LaneID: "unrelated-lane"})
|
||||
}
|
||||
outputs = append(outputs, producerOutput)
|
||||
|
||||
sets, provenance, err := buildStepReferenceSets(input, step, outputs)
|
||||
if err != nil {
|
||||
t.Fatalf("buildStepReferenceSets() error = %v", err)
|
||||
}
|
||||
if len(sets) != 3 || len(provenance) != 3 {
|
||||
t.Fatalf("reference sets/provenance = %d/%d, want 3/3", len(sets), len(provenance))
|
||||
}
|
||||
for target, set := range sets {
|
||||
item := set.Slots["producer-output"].Items[0]
|
||||
if item.Producer.StepID != "step-1" || item.Producer.LaneID != "notes" || string(item.Content) == "" {
|
||||
t.Fatalf("target %v item = %#v, want canonical producer artifact", target, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedReferenceAcceptsTypedEmptyCollection(t *testing.T) {
|
||||
input, step, producerOutput := handoffFixture(t, codecNotes{})
|
||||
sets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{producerOutput})
|
||||
@@ -128,6 +152,12 @@ func TestGeneratedReferenceRejectsInvalidProducerOutputsDeterministically(t *tes
|
||||
{name: "multiple", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
|
||||
return []contracts.SerializedOutput{output, output}
|
||||
}, want: "exactly one is required"},
|
||||
{name: "multiple after identifier normalization", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
|
||||
duplicate := contracts.CloneSerializedOutput(output)
|
||||
duplicate.StepID = " step-1 "
|
||||
duplicate.LaneID = " notes "
|
||||
return []contracts.SerializedOutput{output, duplicate}
|
||||
}, want: "producer has 2 accepted normalized outputs; exactly one is required"},
|
||||
{name: "kind mismatch", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
|
||||
return []contracts.SerializedOutput{output}
|
||||
}, mutate: func(target *ResolvedReferenceTarget, _ *contracts.SerializedOutput) {
|
||||
@@ -167,6 +197,17 @@ func TestGeneratedReferenceRejectsInvalidProducerOutputsDeterministically(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIndexGeneratedOutputs(b *testing.B) {
|
||||
outputs := make([]contracts.SerializedOutput, 0, 1024)
|
||||
for index := range 1024 {
|
||||
outputs = append(outputs, contracts.SerializedOutput{StepID: fmt.Sprintf("step-%d", index), LaneID: fmt.Sprintf("lane-%d", index)})
|
||||
}
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
_ = indexGeneratedOutputs(outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedReferenceFingerprintChangesWithCanonicalContent(t *testing.T) {
|
||||
input, step, first := handoffFixture(t, codecNotes{Items: []string{"first"}})
|
||||
firstSets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{first})
|
||||
@@ -185,6 +226,27 @@ func TestGeneratedReferenceFingerprintChangesWithCanonicalContent(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedReferenceFingerprintChangesWithCanonicalSize(t *testing.T) {
|
||||
input, step, producerOutput := handoffFixture(t, codecNotes{Items: []string{"first"}})
|
||||
sets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{producerOutput})
|
||||
if err != nil {
|
||||
t.Fatalf("build reference set: %v", err)
|
||||
}
|
||||
key := keyForReferenceTarget(step.lanes[0].resolved.ExtractReferences)
|
||||
firstDeps := generatedReferenceDependencies(sets[key])
|
||||
changed := CloneReferenceSet(sets[key])
|
||||
item := changed.Slots["producer-output"].Items[0]
|
||||
item.SizeBytes++
|
||||
changed.Slots["producer-output"] = contracts.ResolvedReferenceSlot{
|
||||
Slot: changed.Slots["producer-output"].Slot,
|
||||
Items: []contracts.ReferenceItem{item},
|
||||
}
|
||||
secondDeps := generatedReferenceDependencies(changed)
|
||||
if reflect.DeepEqual(firstDeps, secondDeps) {
|
||||
t.Fatalf("generated dependencies = %#v, want size-sensitive fingerprint", firstDeps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerHandsOffAcceptedNormalizedOutputBeforeConsumerLanes(t *testing.T) {
|
||||
input, _, _ := handoffFixture(t, codecNotes{Items: []string{"first"}})
|
||||
prepared := input.Prepared
|
||||
|
||||
@@ -305,6 +305,7 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
|
||||
if name != "extract" {
|
||||
return
|
||||
}
|
||||
request.Options["nested"].(map[string]any)["value"] = "mutated by extractor builder"
|
||||
slot := request.References.Slots["extract"]
|
||||
slot.Items[0].Content = []byte("mutated by extractor builder")
|
||||
request.References.Slots["extract"] = slot
|
||||
@@ -318,6 +319,7 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
|
||||
resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet = constructionReferenceSet("extract", "extract reference")
|
||||
resolved.Steps[0].ArtifactLanes[0].MergeReferences.ReferenceSet = constructionReferenceSet("merge", "merge reference")
|
||||
resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.ReferenceSet = constructionReferenceSet("normalize", "normalize reference")
|
||||
resolved.Steps[0].ArtifactLanes[0].Extract.Options = constructionBuildRequest().Options
|
||||
|
||||
prepared, err := Prepare(resolved, registries, ModuleDependencies{})
|
||||
if err != nil {
|
||||
@@ -339,6 +341,15 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
|
||||
if got := constructionReferenceContent(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" {
|
||||
t.Fatalf("resolved extract references = %q, want original content", got)
|
||||
}
|
||||
if got := constructionReferenceContent(prepared.resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet); got != "extract reference" {
|
||||
t.Fatalf("prepared extract references = %q, want original content", got)
|
||||
}
|
||||
if got := resolved.Steps[0].ArtifactLanes[0].Extract.Options["nested"].(map[string]any)["value"]; got != "original" {
|
||||
t.Fatalf("resolved extract options = %#v, want original nested value", got)
|
||||
}
|
||||
if got := prepared.resolved.Steps[0].ArtifactLanes[0].Extract.Options["nested"].(map[string]any)["value"]; got != "original" {
|
||||
t.Fatalf("prepared extract options = %#v, want original nested value", got)
|
||||
}
|
||||
|
||||
_, err = prepared.Steps[0].lanes[0].typed.extract(context.Background(), prepared.Steps[0].lanes[0].typed.extractor, contracts.TypedExtractionRequest{
|
||||
References: CloneReferenceSet(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet),
|
||||
@@ -354,6 +365,163 @@ func TestPrepareDeliversTargetReferencesAsIndependentBuildInputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredBuildersReceiveIndependentBuildRequests(t *testing.T) {
|
||||
request := constructionBuildRequest()
|
||||
probe := buildRequestMutationProbe{t: t, want: cloneBuildRequest(request)}
|
||||
|
||||
inputs := NewInputAdapterRegistry()
|
||||
if err := inputs.RegisterBuilderWithSpec(testModuleSpec("input", StageInput), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.InputAdapter, error) {
|
||||
probe.observe(request)
|
||||
return &constructionInput{key: "input"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chunkers := NewChunkerRegistry()
|
||||
if err := chunkers.RegisterBuilderWithSpec(testModuleSpec("chunk", StageChunk), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Chunker, error) {
|
||||
probe.observe(request)
|
||||
return &typedTestChunker{key: "chunk"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outputs := NewOutputEncoderRegistry()
|
||||
if err := outputs.RegisterBuilderWithSpec(testModuleSpec("output", StageOutput), rejectUnconfiguredOptions, func(request BuildRequest) (contracts.OutputEncoder, error) {
|
||||
probe.observe(request)
|
||||
return &typedTestOutput{key: "output"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
extractors := NewExtractorRegistry()
|
||||
extractSpec := testModuleSpec("extract", StageExtract)
|
||||
extractSpec.ArtifactKind = "test/notes"
|
||||
if err := RegisterExtractorBuilder(extractors, extractSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Extractor[codecNotes], error) {
|
||||
probe.observe(request)
|
||||
return typedTestExtractor[codecNotes]{key: "extract"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mergers := NewMergerRegistry()
|
||||
mergeSpec := testModuleSpec("merge", StageMerge)
|
||||
mergeSpec.ArtifactKind = "test/notes"
|
||||
if err := RegisterMergerBuilder(mergers, mergeSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Merger[codecNotes], error) {
|
||||
probe.observe(request)
|
||||
return typedTestMerger[codecNotes]{key: "merge"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normalizers := NewNormalizerRegistry()
|
||||
normalizeSpec := testModuleSpec("normalize", StageNormalize)
|
||||
normalizeSpec.ArtifactKind = "test/notes"
|
||||
if err := RegisterNormalizerBuilder(normalizers, normalizeSpec, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.Normalizer[codecNotes], error) {
|
||||
probe.observe(request)
|
||||
return typedTestNormalizer[codecNotes]{key: "normalize"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
validators := NewValidatorRegistry()
|
||||
if err := RegisterChunkValidatorBuilder(validators, ValidatorSpec{Key: "chunk-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.ChunkValidator, error) {
|
||||
probe.observe(request)
|
||||
return typedTestChunkValidator{key: "chunk-validator"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RegisterTypedValidatorBuilder(validators, "test/notes", ValidatorSpec{Key: "typed-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.TypedValidator[codecNotes], error) {
|
||||
probe.observe(request)
|
||||
return typedTestValidator[codecNotes]{key: "typed-validator"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RegisterSerializedValidatorBuilder(validators, SerializedValidatorSpec{ValidatorSpec: ValidatorSpec{Key: "serialized-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, SupportsArtifacts: true}, rejectUnconfiguredOptions, func(request BuildRequest) (contracts.SerializedValidator, error) {
|
||||
probe.observe(request)
|
||||
return typedTestSerializedValidator{key: "serialized-validator"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
builders := []struct {
|
||||
name string
|
||||
call func() error
|
||||
}{
|
||||
{name: "input", call: func() error { _, err := inputs.BuildWithRequest("input", request); return err }},
|
||||
{name: "chunk", call: func() error { _, err := chunkers.BuildWithRequest("chunk", request); return err }},
|
||||
{name: "output", call: func() error { _, err := outputs.BuildWithRequest("output", request); return err }},
|
||||
{name: "extract", call: func() error {
|
||||
entry, _ := extractors.typedEntry("extract")
|
||||
_, err := entry.builder(request)
|
||||
return err
|
||||
}},
|
||||
{name: "merge", call: func() error {
|
||||
entry, _ := mergers.typedEntry("merge", "test/notes")
|
||||
_, err := entry.builder(request)
|
||||
return err
|
||||
}},
|
||||
{name: "normalize", call: func() error {
|
||||
entry, _ := normalizers.typedEntry("normalize", "test/notes")
|
||||
_, err := entry.builder(request)
|
||||
return err
|
||||
}},
|
||||
{name: "chunk validator", call: func() error {
|
||||
entry, _ := validators.chunkEntry("chunk-validator")
|
||||
_, err := entry.builder(request)
|
||||
return err
|
||||
}},
|
||||
{name: "typed validator", call: func() error {
|
||||
entry, _ := validators.typedEntry("typed-validator", "test/notes")
|
||||
_, err := entry.builder(request)
|
||||
return err
|
||||
}},
|
||||
{name: "serialized validator", call: func() error {
|
||||
entry, _ := validators.serializedEntry("serialized-validator")
|
||||
_, err := entry.builder(request)
|
||||
return err
|
||||
}},
|
||||
}
|
||||
for _, builder := range builders {
|
||||
t.Run(builder.name, func(t *testing.T) {
|
||||
if err := builder.call(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !reflect.DeepEqual(request, probe.want) {
|
||||
t.Fatalf("build request = %#v, want unchanged %#v", request, probe.want)
|
||||
}
|
||||
}
|
||||
|
||||
type buildRequestMutationProbe struct {
|
||||
t *testing.T
|
||||
want BuildRequest
|
||||
}
|
||||
|
||||
func (probe buildRequestMutationProbe) observe(request BuildRequest) {
|
||||
probe.t.Helper()
|
||||
if !reflect.DeepEqual(request, probe.want) {
|
||||
probe.t.Fatalf("builder request = %#v, want independently owned %#v", request, probe.want)
|
||||
}
|
||||
options := request.Options["nested"].(map[string]any)
|
||||
options["value"] = "mutated"
|
||||
request.Options["items"].([]any)[0].(map[string]any)["value"] = "mutated"
|
||||
request.Options["bytes"].([]byte)[0] = 'x'
|
||||
slot := request.References.Slots["reference"]
|
||||
slot.Items[0].Content[0] = 'x'
|
||||
slot.Items = append(slot.Items, contracts.ReferenceItem{SlotName: "reference", Content: []byte("extra")})
|
||||
request.References.Slots["reference"] = slot
|
||||
delete(request.References.Slots, "unused")
|
||||
}
|
||||
|
||||
func constructionBuildRequest() BuildRequest {
|
||||
return BuildRequest{
|
||||
Options: map[string]any{
|
||||
"nested": map[string]any{"value": "original"},
|
||||
"items": []any{map[string]any{"value": "original"}},
|
||||
"bytes": []byte("original"),
|
||||
},
|
||||
References: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"reference": {Slot: contracts.ReferenceSlot{Name: "reference"}, Items: []contracts.ReferenceItem{{SlotName: "reference", Content: []byte("original")}}},
|
||||
"unused": {Slot: contracts.ReferenceSlot{Name: "unused"}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareFailuresOccurBeforeInputParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -100,7 +100,7 @@ func Prepare(resolved ResolvedPipeline, registries Registries, deps ModuleDepend
|
||||
artifactCodecs: registries.ArtifactCodecs,
|
||||
}
|
||||
request := func(binding ModuleBinding, references contracts.ReferenceSet) BuildRequest {
|
||||
return BuildRequest{Dependencies: deps, Options: cloneOptions(binding.Options), References: references}
|
||||
return BuildRequest{Dependencies: deps, Options: binding.Options, References: references}
|
||||
}
|
||||
|
||||
input, err := registries.Inputs.BuildWithRequest(stable.Input.Module, request(stable.Input, contracts.ReferenceSet{}))
|
||||
@@ -202,7 +202,7 @@ func prepareEvidencePlan(resolved ResolvedPipeline, registries Registries, outpu
|
||||
func prepareLane(pipeline ResolvedPipeline, lane ResolvedArtifactLane, registries Registries, deps ModuleDependencies) (preparedLaneExecutor, error) {
|
||||
executor := preparedLaneExecutor{resolved: cloneResolvedArtifactLane(lane)}
|
||||
request := func(binding ModuleBinding, references contracts.ReferenceSet) BuildRequest {
|
||||
return BuildRequest{Dependencies: deps, Options: cloneOptions(binding.Options), References: references}
|
||||
return BuildRequest{Dependencies: deps, Options: binding.Options, References: references}
|
||||
}
|
||||
extractEntry, ok := registries.Extractors.typedEntry(lane.Extract.Module)
|
||||
if !ok {
|
||||
@@ -259,7 +259,7 @@ func prepareValidatorChain(pipeline ResolvedPipeline, registries Registries, dep
|
||||
resolved := resolvedValidatorChain(stage, laneID, moduleKey, pipeline.ValidatorChains)
|
||||
prepared := preparedValidatorChain{resolved: resolved}
|
||||
for _, validator := range resolved.Validators {
|
||||
request := BuildRequest{Dependencies: deps, Options: cloneOptions(validator.Binding.Options), References: references}
|
||||
request := BuildRequest{Dependencies: deps, Options: validator.Binding.Options, References: references}
|
||||
built, err := buildPreparedValidator(registries.Validators, validator, request)
|
||||
if err != nil {
|
||||
return preparedValidatorChain{}, constructionError(pipeline.ID, laneID, stage, moduleKey, validator.Binding.Module, err)
|
||||
@@ -280,7 +280,7 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida
|
||||
if !ok {
|
||||
return preparedValidator{}, fmt.Errorf("typed construction entry is not registered")
|
||||
}
|
||||
implementation, err = entry.builder(cloneBuildRequest(request))
|
||||
implementation, err = entry.builder(request)
|
||||
prepared.typed = implementation
|
||||
prepared.typedValidate = entry.validate
|
||||
case ValidatorTargetChunk:
|
||||
@@ -288,14 +288,14 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida
|
||||
if !ok {
|
||||
return preparedValidator{}, fmt.Errorf("chunk construction entry is not registered")
|
||||
}
|
||||
prepared.chunk, err = entry.builder(cloneBuildRequest(request))
|
||||
prepared.chunk, err = entry.builder(request)
|
||||
implementation = prepared.chunk
|
||||
case ValidatorTargetSerialized:
|
||||
entry, ok := registry.serializedEntry(key)
|
||||
if !ok {
|
||||
return preparedValidator{}, fmt.Errorf("serialized construction entry is not registered")
|
||||
}
|
||||
prepared.serialized, err = entry.builder(cloneBuildRequest(request))
|
||||
prepared.serialized, err = entry.builder(request)
|
||||
implementation = prepared.serialized
|
||||
default:
|
||||
return preparedValidator{}, fmt.Errorf("validator construction target %q is not supported", resolved.Target)
|
||||
@@ -323,7 +323,7 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida
|
||||
}
|
||||
|
||||
func buildErasedModule(builder func(BuildRequest) (any, error), request BuildRequest, key, kind string) (any, error) {
|
||||
implementation, err := builder(cloneBuildRequest(request))
|
||||
implementation, err := builder(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -305,7 +305,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
|
||||
return ResolvedPipeline{}, err
|
||||
}
|
||||
|
||||
input := resolveBinding(profile.Input, "")
|
||||
input, err := resolveBinding(profile.Input, "", fmt.Sprintf("pipeline %q input reference slot", pipelineID))
|
||||
if err != nil {
|
||||
return ResolvedPipeline{}, err
|
||||
}
|
||||
if input.Module == "" {
|
||||
return ResolvedPipeline{}, fmt.Errorf("pipeline %q input module must not be empty", pipelineID)
|
||||
}
|
||||
@@ -320,7 +323,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
|
||||
}
|
||||
capabilities.add(inputModuleSpec.Provides...)
|
||||
|
||||
chunk := resolveBinding(profile.Chunk, DefaultChunkModule)
|
||||
chunk, err := resolveBinding(profile.Chunk, DefaultChunkModule, fmt.Sprintf("pipeline %q chunk reference slot", pipelineID))
|
||||
if err != nil {
|
||||
return ResolvedPipeline{}, err
|
||||
}
|
||||
chunkSpec, err := chunkerSpec(catalog, chunk.Module)
|
||||
if err != nil {
|
||||
return ResolvedPipeline{}, moduleLookupError(pipelineID, "", StageChunk, chunk.Module, err)
|
||||
@@ -363,6 +369,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
|
||||
if err != nil {
|
||||
return ResolvedPipeline{}, err
|
||||
}
|
||||
output, err := resolveBinding(profile.Output, DefaultOutputModule, fmt.Sprintf("pipeline %q output reference slot", pipelineID))
|
||||
if err != nil {
|
||||
return ResolvedPipeline{}, err
|
||||
}
|
||||
resolved := ResolvedPipeline{
|
||||
ID: pipelineID,
|
||||
Input: input,
|
||||
@@ -370,7 +380,7 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
|
||||
Chunk: chunk,
|
||||
ChunkExecutionClass: chunkSpec.ExecutionClass,
|
||||
ChunkReferences: referenceTarget(StageChunk, "", chunk.Module, chunkReferences),
|
||||
Output: resolveBinding(profile.Output, DefaultOutputModule),
|
||||
Output: output,
|
||||
}
|
||||
chunkValidatorChain, err := resolveValidatorChain(pipelineID, "", StageChunk, chunk.Module, chunk.Validators, "", nil, catalog)
|
||||
if err != nil {
|
||||
@@ -485,13 +495,29 @@ func resolveArtifactLane(
|
||||
inherited capabilitySet,
|
||||
catalog ModuleCatalog,
|
||||
) (ResolvedArtifactLane, []ResolvedValidatorChain, capabilitySet, error) {
|
||||
extract, err := resolveBinding(profile.Extract, "", fmt.Sprintf("pipeline %q step %q lane %q extract reference slot", pipelineID, stepID, laneID))
|
||||
if err != nil {
|
||||
return ResolvedArtifactLane{}, nil, nil, err
|
||||
}
|
||||
merge, err := resolveBinding(profile.Merge, DefaultMergeModule, fmt.Sprintf("pipeline %q step %q lane %q merge reference slot", pipelineID, stepID, laneID))
|
||||
if err != nil {
|
||||
return ResolvedArtifactLane{}, nil, nil, err
|
||||
}
|
||||
normalize, err := resolveBinding(profile.Normalize, DefaultNormalizeModule, fmt.Sprintf("pipeline %q step %q lane %q normalize reference slot", pipelineID, stepID, laneID))
|
||||
if err != nil {
|
||||
return ResolvedArtifactLane{}, nil, nil, err
|
||||
}
|
||||
validators, err := resolveBindings(profile.Validators, "", fmt.Sprintf("pipeline %q step %q lane %q validator reference slot", pipelineID, stepID, laneID))
|
||||
if err != nil {
|
||||
return ResolvedArtifactLane{}, nil, nil, err
|
||||
}
|
||||
lane := ResolvedArtifactLane{
|
||||
StepID: strings.TrimSpace(stepID),
|
||||
ID: laneID,
|
||||
Extract: resolveBinding(profile.Extract, ""),
|
||||
Merge: resolveBinding(profile.Merge, DefaultMergeModule),
|
||||
Normalize: resolveBinding(profile.Normalize, DefaultNormalizeModule),
|
||||
Validators: resolveBindings(profile.Validators, ""),
|
||||
Extract: extract,
|
||||
Merge: merge,
|
||||
Normalize: normalize,
|
||||
Validators: validators,
|
||||
}
|
||||
if lane.Extract.Module == "" {
|
||||
return ResolvedArtifactLane{}, nil, nil, fmt.Errorf("pipeline %q lane %q extract module must not be empty", pipelineID, laneID)
|
||||
@@ -516,6 +542,7 @@ func resolveArtifactLane(
|
||||
extractReferences := mergeReferenceMaps(profile.References, lane.Extract.References)
|
||||
references, err := resolveReferenceTargetBindings(referenceResolutionTarget{
|
||||
PipelineID: pipelineID,
|
||||
StepID: strings.TrimSpace(stepID),
|
||||
LaneID: laneID,
|
||||
Stage: StageExtract,
|
||||
Module: lane.Extract.Module,
|
||||
@@ -541,6 +568,7 @@ func resolveArtifactLane(
|
||||
}
|
||||
mergeReferences, err := resolveReferenceTargetBindings(referenceResolutionTarget{
|
||||
PipelineID: pipelineID,
|
||||
StepID: strings.TrimSpace(stepID),
|
||||
LaneID: laneID,
|
||||
Stage: StageMerge,
|
||||
Module: lane.Merge.Module,
|
||||
@@ -566,6 +594,7 @@ func resolveArtifactLane(
|
||||
}
|
||||
normalizeReferences, err := resolveReferenceTargetBindings(referenceResolutionTarget{
|
||||
PipelineID: pipelineID,
|
||||
StepID: strings.TrimSpace(stepID),
|
||||
LaneID: laneID,
|
||||
Stage: StageNormalize,
|
||||
Module: lane.Normalize.Module,
|
||||
@@ -925,7 +954,7 @@ func validatePipelineReferenceDefaults(
|
||||
lanesByID map[string]ArtifactLaneProfile,
|
||||
catalog ModuleCatalog,
|
||||
) error {
|
||||
normalizedPipelineReferences, err := normalizedReferenceMap(pipelineReferences, fmt.Sprintf("pipeline %q reference slot", pipelineID))
|
||||
normalizedPipelineReferences, err := normalizeReferenceMap(pipelineReferences, fmt.Sprintf("pipeline %q reference slot", pipelineID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -939,38 +968,38 @@ func validatePipelineReferenceDefaults(
|
||||
}
|
||||
for _, laneID := range sortedArtifactLaneProfileKeys(lanesByID) {
|
||||
laneProfile := lanesByID[laneID]
|
||||
extract := resolveBinding(laneProfile.Extract, "")
|
||||
if extract.Module == "" {
|
||||
extractModule := resolveModuleKey(laneProfile.Extract.Module, "")
|
||||
if extractModule == "" {
|
||||
return fmt.Errorf("pipeline %q lane %q extract module must not be empty", pipelineID, laneID)
|
||||
}
|
||||
extractSpec, err := extractorSpec(catalog, extract.Module)
|
||||
extractSpec, err := extractorSpec(catalog, extractModule)
|
||||
if err != nil {
|
||||
return moduleLookupError(pipelineID, laneID, StageExtract, extract.Module, err)
|
||||
return moduleLookupError(pipelineID, laneID, StageExtract, extractModule, err)
|
||||
}
|
||||
for _, slot := range extractSpec.ReferenceSlots {
|
||||
declaredByAnyTarget[slot.Name] = struct{}{}
|
||||
}
|
||||
|
||||
merge := resolveBinding(laneProfile.Merge, DefaultMergeModule)
|
||||
mergeModule := resolveModuleKey(laneProfile.Merge.Module, DefaultMergeModule)
|
||||
var artifactType reflect.Type
|
||||
artifactKind := extractSpec.ArtifactKind
|
||||
if artifactKind != "" && catalog.Extractors != nil {
|
||||
if entry, ok := catalog.Extractors.typedEntry(extract.Module); ok {
|
||||
if entry, ok := catalog.Extractors.typedEntry(extractModule); ok {
|
||||
artifactType = entry.valueType
|
||||
}
|
||||
}
|
||||
mergeSpec, err := mergerSpecForArtifact(catalog, merge.Module, artifactKind, artifactType)
|
||||
mergeSpec, err := mergerSpecForArtifact(catalog, mergeModule, artifactKind, artifactType)
|
||||
if err != nil {
|
||||
return moduleLookupError(pipelineID, laneID, StageMerge, merge.Module, err)
|
||||
return moduleLookupError(pipelineID, laneID, StageMerge, mergeModule, err)
|
||||
}
|
||||
for _, slot := range mergeSpec.ReferenceSlots {
|
||||
declaredByAnyTarget[slot.Name] = struct{}{}
|
||||
}
|
||||
|
||||
normalize := resolveBinding(laneProfile.Normalize, DefaultNormalizeModule)
|
||||
normalizeSpec, err := normalizerSpecForArtifact(catalog, normalize.Module, artifactKind, artifactType)
|
||||
normalizeModule := resolveModuleKey(laneProfile.Normalize.Module, DefaultNormalizeModule)
|
||||
normalizeSpec, err := normalizerSpecForArtifact(catalog, normalizeModule, artifactKind, artifactType)
|
||||
if err != nil {
|
||||
return moduleLookupError(pipelineID, laneID, StageNormalize, normalize.Module, err)
|
||||
return moduleLookupError(pipelineID, laneID, StageNormalize, normalizeModule, err)
|
||||
}
|
||||
for _, slot := range normalizeSpec.ReferenceSlots {
|
||||
declaredByAnyTarget[slot.Name] = struct{}{}
|
||||
@@ -987,6 +1016,7 @@ func validatePipelineReferenceDefaults(
|
||||
|
||||
type referenceResolutionTarget struct {
|
||||
PipelineID string
|
||||
StepID string
|
||||
LaneID string
|
||||
Stage ModuleStage
|
||||
Module string
|
||||
@@ -1046,7 +1076,7 @@ func resolveReferenceTargetBindings(target referenceResolutionTarget) ([]Referen
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedPipelineReferences, err := normalizedReferenceMap(target.PipelineReferences, fmt.Sprintf("pipeline %q reference slot", target.PipelineID))
|
||||
normalizedPipelineReferences, err := normalizeReferenceMap(target.PipelineReferences, fmt.Sprintf("pipeline %q reference slot", target.PipelineID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1059,7 +1089,7 @@ func resolveReferenceTargetBindings(target referenceResolutionTarget) ([]Referen
|
||||
}
|
||||
}
|
||||
|
||||
normalizedLocalReferences, err := normalizedReferenceMap(target.LocalReferences, referenceTargetSlotLabel(target))
|
||||
normalizedLocalReferences, err := normalizeReferenceMap(target.LocalReferences, referenceTargetSlotLabel(target))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1166,6 +1196,9 @@ func sortedReferenceBindings(bindings map[string]ReferenceBinding) []ReferenceBi
|
||||
|
||||
func referenceTargetErrorContext(target referenceResolutionTarget) string {
|
||||
if target.LaneID != "" {
|
||||
if target.StepID != "" {
|
||||
return fmt.Sprintf("pipeline %q step %q lane %q %s module %q", target.PipelineID, target.StepID, target.LaneID, target.Stage, target.Module)
|
||||
}
|
||||
return fmt.Sprintf("pipeline %q lane %q %s module %q", target.PipelineID, target.LaneID, target.Stage, target.Module)
|
||||
}
|
||||
return fmt.Sprintf("pipeline %q %s module %q", target.PipelineID, target.Stage, target.Module)
|
||||
@@ -1173,17 +1206,22 @@ func referenceTargetErrorContext(target referenceResolutionTarget) string {
|
||||
|
||||
func referenceTargetSlotLabel(target referenceResolutionTarget) string {
|
||||
if target.LaneID != "" {
|
||||
if target.StepID != "" {
|
||||
return fmt.Sprintf("pipeline %q step %q lane %q %s reference slot", target.PipelineID, target.StepID, target.LaneID, target.Stage)
|
||||
}
|
||||
return fmt.Sprintf("pipeline %q lane %q %s reference slot", target.PipelineID, target.LaneID, target.Stage)
|
||||
}
|
||||
return fmt.Sprintf("pipeline %q %s reference slot", target.PipelineID, target.Stage)
|
||||
}
|
||||
|
||||
func normalizedReferenceMap(values map[string]ReferenceSource, keyName string) (map[string]ReferenceSource, error) {
|
||||
func normalizeReferenceMap(values map[string]ReferenceSource, keyName string) (map[string]ReferenceSource, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make(map[string]ReferenceSource, len(values))
|
||||
for rawSlotName, rawSource := range values {
|
||||
rawSlotNames := sortedStringMapKeys(values)
|
||||
for _, rawSlotName := range rawSlotNames {
|
||||
rawSource := values[rawSlotName]
|
||||
slotName := strings.TrimSpace(rawSlotName)
|
||||
if slotName == "" {
|
||||
return nil, fmt.Errorf("%s must not be empty", keyName)
|
||||
@@ -1236,20 +1274,29 @@ func sortedReferenceBindingKeys(values map[string]ReferenceBinding) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
func resolveBinding(binding ModuleBinding, defaultModule string) ModuleBinding {
|
||||
module := strings.TrimSpace(binding.Module)
|
||||
if module == "" {
|
||||
module = defaultModule
|
||||
}
|
||||
func resolveBinding(binding ModuleBinding, defaultModule string, referenceSlotLabel string) (ModuleBinding, error) {
|
||||
module := resolveModuleKey(binding.Module, defaultModule)
|
||||
llmProfile := strings.TrimSpace(binding.LLMProfile)
|
||||
references, err := normalizeReferenceMap(binding.References, referenceSlotLabel)
|
||||
if err != nil {
|
||||
return ModuleBinding{}, err
|
||||
}
|
||||
return ModuleBinding{
|
||||
Module: module,
|
||||
LLMProfile: llmProfile,
|
||||
Retries: binding.Retries,
|
||||
Options: cloneOptions(binding.Options),
|
||||
References: normalizeReferenceMap(binding.References),
|
||||
References: references,
|
||||
Validators: cloneValidatorOverride(binding.Validators),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveModuleKey(module string, defaultModule string) string {
|
||||
module = strings.TrimSpace(module)
|
||||
if module == "" {
|
||||
return defaultModule
|
||||
}
|
||||
return module
|
||||
}
|
||||
|
||||
func applyEffectiveLLMProfiles(resolved *ResolvedPipeline, pipelineProfile, overrideProfile string) error {
|
||||
@@ -1312,17 +1359,20 @@ func applyEffectiveLLMProfiles(resolved *ResolvedPipeline, pipelineProfile, over
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveBindings(bindings []ModuleBinding, defaultModule string) []ModuleBinding {
|
||||
func resolveBindings(bindings []ModuleBinding, defaultModule string, referenceSlotLabel string) ([]ModuleBinding, error) {
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
resolved := make([]ModuleBinding, 0, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
resolvedBinding := resolveBinding(binding, defaultModule)
|
||||
for index, binding := range bindings {
|
||||
resolvedBinding, err := resolveBinding(binding, defaultModule, fmt.Sprintf("%s %d", referenceSlotLabel, index))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved = append(resolved, resolvedBinding)
|
||||
}
|
||||
return resolved
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func cloneOptions(options map[string]any) map[string]any {
|
||||
@@ -1358,25 +1408,6 @@ func cloneOptionValue(value any) any {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeReferenceMap(values map[string]ReferenceSource) map[string]ReferenceSource {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]ReferenceSource, len(values))
|
||||
keys := make([]string, 0, len(values))
|
||||
rawByNormalized := make(map[string]string, len(values))
|
||||
for rawKey := range values {
|
||||
key := strings.TrimSpace(rawKey)
|
||||
rawByNormalized[key] = rawKey
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
out[key] = cloneReferenceSource(values[rawByNormalized[key]])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func selectedArtifactLanes(pipelineID string, artifacts map[string]ArtifactLaneProfile, options ResolveOptions) (map[string]ArtifactLaneProfile, []string, error) {
|
||||
lanesByID := make(map[string]ArtifactLaneProfile, len(artifacts))
|
||||
for rawLaneID, lane := range artifacts {
|
||||
|
||||
@@ -159,6 +159,85 @@ func TestResolvePipelineAppliesDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePipelineRejectsReferenceSlotCollisionsAfterTrimming(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
configure func(*PipelineProfile)
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "chunk",
|
||||
configure: func(profile *PipelineProfile) {
|
||||
profile.Chunk = ModuleBinding{Module: DefaultChunkModule, References: map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}}
|
||||
},
|
||||
want: []string{`pipeline "references"`, "chunk", `reference slot "rules" is duplicated after trimming`},
|
||||
},
|
||||
{
|
||||
name: "extract",
|
||||
configure: func(profile *PipelineProfile) {
|
||||
lane := profile.Artifacts["lane"]
|
||||
lane.Extract.References = map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}
|
||||
profile.Artifacts["lane"] = lane
|
||||
},
|
||||
want: []string{`pipeline "references"`, `step "default"`, `lane "lane"`, "extract", `reference slot "rules" is duplicated after trimming`},
|
||||
},
|
||||
{
|
||||
name: "merge",
|
||||
configure: func(profile *PipelineProfile) {
|
||||
lane := profile.Artifacts["lane"]
|
||||
lane.Merge = ModuleBinding{Module: DefaultMergeModule, References: map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}}
|
||||
profile.Artifacts["lane"] = lane
|
||||
},
|
||||
want: []string{`pipeline "references"`, `step "default"`, `lane "lane"`, "merge", `reference slot "rules" is duplicated after trimming`},
|
||||
},
|
||||
{
|
||||
name: "normalize",
|
||||
configure: func(profile *PipelineProfile) {
|
||||
lane := profile.Artifacts["lane"]
|
||||
lane.Normalize = ModuleBinding{Module: DefaultNormalizeModule, References: map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}}
|
||||
profile.Artifacts["lane"] = lane
|
||||
},
|
||||
want: []string{`pipeline "references"`, `step "default"`, `lane "lane"`, "normalize", `reference slot "rules" is duplicated after trimming`},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := PipelineProfile{
|
||||
ID: "references",
|
||||
Input: Binding("text"),
|
||||
Artifacts: map[string]ArtifactLaneProfile{
|
||||
"lane": {Extract: Binding("event-extractor")},
|
||||
},
|
||||
}
|
||||
tt.configure(&profile)
|
||||
_, err := ResolvePipeline(profile, ResolveOptions{}, newProfileCatalog(t))
|
||||
if err == nil {
|
||||
t.Fatal("ResolvePipeline() error = nil, want normalized reference collision")
|
||||
}
|
||||
for _, fragment := range tt.want {
|
||||
if !strings.Contains(err.Error(), fragment) {
|
||||
t.Fatalf("ResolvePipeline() error = %q, want context %q", err, fragment)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePipelineRejectsEmptyReferenceSlotAfterTrimming(t *testing.T) {
|
||||
_, err := ResolvePipeline(PipelineProfile{
|
||||
ID: "references",
|
||||
Input: Binding("text"),
|
||||
Chunk: ModuleBinding{Module: DefaultChunkModule, References: map[string]ReferenceSource{
|
||||
" \t ": ExternalReference("rules.md"),
|
||||
}},
|
||||
Artifacts: map[string]ArtifactLaneProfile{
|
||||
"lane": {Extract: Binding("event-extractor")},
|
||||
},
|
||||
}, ResolveOptions{}, newProfileCatalog(t))
|
||||
if err == nil || !strings.Contains(err.Error(), `pipeline "references" chunk reference slot must not be empty`) {
|
||||
t.Fatalf("ResolvePipeline() error = %v, want contextual empty reference-slot rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePipelineAppliesEffectiveLLMProfiles(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
|
||||
@@ -3,7 +3,10 @@ package pipeline
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"mime"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -124,10 +127,30 @@ func materializeReferenceTarget(
|
||||
if err != nil {
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q: %w", referenceTargetContext(pipelineID, target), slotName, binding.Source, err)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q read %q: %w", referenceTargetContext(pipelineID, target), slotName, path, err)
|
||||
}
|
||||
regularFileSize := int64(0)
|
||||
if info, statErr := file.Stat(); statErr == nil && info.Mode().IsRegular() {
|
||||
regularFileSize = info.Size()
|
||||
}
|
||||
content, err := readReferenceContent(file, slot.MaxBytes)
|
||||
closeErr := file.Close()
|
||||
if err == nil && closeErr != nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
var sizeErr *referenceSizeLimitError
|
||||
if errors.As(err, &sizeErr) {
|
||||
sizeBytes := sizeErr.SizeBytes
|
||||
if regularFileSize > sizeBytes {
|
||||
sizeBytes = regularFileSize
|
||||
}
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q is %d bytes, limit %d", referenceTargetContext(pipelineID, target), slotName, path, sizeBytes, slot.MaxBytes)
|
||||
}
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q read %q: %w", referenceTargetContext(pipelineID, target), slotName, path, err)
|
||||
}
|
||||
if !utf8.Valid(content) {
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q must be UTF-8 text", referenceTargetContext(pipelineID, target), slotName, path)
|
||||
}
|
||||
@@ -135,9 +158,6 @@ func materializeReferenceTarget(
|
||||
if !referenceMediaTypeAccepted(mediaType, slot.AcceptedMediaTypes) {
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q media type %q is not accepted", referenceTargetContext(pipelineID, target), slotName, path, mediaType)
|
||||
}
|
||||
if slot.MaxBytes > 0 && int64(len(content)) > slot.MaxBytes {
|
||||
return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q is %d bytes, limit %d", referenceTargetContext(pipelineID, target), slotName, path, len(content), slot.MaxBytes)
|
||||
}
|
||||
if len(content) == 0 {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: referenceWarningScope(pipelineID, target, slotName),
|
||||
@@ -163,6 +183,32 @@ func materializeReferenceTarget(
|
||||
return set, warnings, nil
|
||||
}
|
||||
|
||||
type referenceSizeLimitError struct {
|
||||
SizeBytes int64
|
||||
MaxBytes int64
|
||||
}
|
||||
|
||||
func (err *referenceSizeLimitError) Error() string {
|
||||
return fmt.Sprintf("reference is %d bytes, limit %d", err.SizeBytes, err.MaxBytes)
|
||||
}
|
||||
|
||||
func readReferenceContent(reader io.Reader, maxBytes int64) ([]byte, error) {
|
||||
if maxBytes <= 0 {
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
if maxBytes == math.MaxInt64 {
|
||||
return nil, fmt.Errorf("maximum reference size %d cannot be safely bounded", maxBytes)
|
||||
}
|
||||
content, err := io.ReadAll(io.LimitReader(reader, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(content)) > maxBytes {
|
||||
return nil, &referenceSizeLimitError{SizeBytes: int64(len(content)), MaxBytes: maxBytes}
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func referenceTargetSpec(target ResolvedReferenceTarget, artifactKind contracts.ArtifactKind, catalog ModuleCatalog) (ModuleSpec, error) {
|
||||
switch target.Stage {
|
||||
case StageChunk:
|
||||
|
||||
@@ -2,6 +2,9 @@ package pipeline
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -12,6 +15,61 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
type countingReader struct {
|
||||
content []byte
|
||||
consumed int
|
||||
}
|
||||
|
||||
func (reader *countingReader) Read(buffer []byte) (int, error) {
|
||||
if len(reader.content) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
read := copy(buffer, reader.content)
|
||||
reader.content = reader.content[read:]
|
||||
reader.consumed += read
|
||||
return read, nil
|
||||
}
|
||||
|
||||
func TestReadReferenceContentBoundsPositiveMaximum(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
maxBytes int64
|
||||
wantContent string
|
||||
wantConsumed int
|
||||
wantLimitErr bool
|
||||
wantReadError bool
|
||||
}{
|
||||
{name: "exact maximum", content: "abc", maxBytes: 3, wantContent: "abc", wantConsumed: 3},
|
||||
{name: "oversized", content: "abcdef", maxBytes: 3, wantConsumed: 4, wantLimitErr: true},
|
||||
{name: "unbounded", content: "abcdef", maxBytes: 0, wantContent: "abcdef", wantConsumed: 6},
|
||||
{name: "negative maximum remains unbounded", content: "abcdef", maxBytes: -1, wantContent: "abcdef", wantConsumed: 6},
|
||||
{name: "overflowing maximum", content: "abcdef", maxBytes: math.MaxInt64, wantConsumed: 0, wantReadError: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reader := &countingReader{content: []byte(tt.content)}
|
||||
content, err := readReferenceContent(reader, tt.maxBytes)
|
||||
if reader.consumed != tt.wantConsumed {
|
||||
t.Fatalf("reader consumed %d bytes, want %d", reader.consumed, tt.wantConsumed)
|
||||
}
|
||||
if got := string(content); got != tt.wantContent {
|
||||
t.Fatalf("content = %q, want %q", got, tt.wantContent)
|
||||
}
|
||||
var sizeErr *referenceSizeLimitError
|
||||
if errors.As(err, &sizeErr) != tt.wantLimitErr {
|
||||
t.Fatalf("error = %v, want limit error=%t", err, tt.wantLimitErr)
|
||||
}
|
||||
if (err != nil) != (tt.wantLimitErr || tt.wantReadError) {
|
||||
t.Fatalf("error = %v, want error=%t", err, tt.wantLimitErr || tt.wantReadError)
|
||||
}
|
||||
if sizeErr != nil && (sizeErr.SizeBytes != 4 || sizeErr.MaxBytes != 3) {
|
||||
t.Fatalf("size error = %#v, want observed size 4 and limit 3", sizeErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeReferencesResolvesPathsAndDigestsContent(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
workingDir := t.TempDir()
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
@@ -80,6 +81,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
if r == nil {
|
||||
return output, fmt.Errorf("runner must not be nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return output, err
|
||||
}
|
||||
if err := validateRunInput(input); err != nil {
|
||||
return output, err
|
||||
}
|
||||
@@ -164,6 +168,10 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
if metadataErr != nil {
|
||||
return failOutput(output), fmt.Errorf("clone input adapter metadata: %w", metadataErr)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
_ = checkpoints.SourceFailed(adapter.Key(), err)
|
||||
return failOutput(output), err
|
||||
}
|
||||
doc, err = adapter.Parse(ctx, contracts.ParseRequest{
|
||||
SourceID: input.SourceID,
|
||||
Path: input.Path,
|
||||
@@ -171,6 +179,10 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
LLMProfile: input.pipeline.Input.LLMProfile,
|
||||
Metadata: requestMetadata,
|
||||
})
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
_ = checkpoints.SourceFailed(adapter.Key(), ctxErr)
|
||||
return failOutput(output), ctxErr
|
||||
}
|
||||
if err != nil {
|
||||
_ = checkpoints.SourceFailed(adapter.Key(), err)
|
||||
return failOutput(output), fmt.Errorf("parse input with adapter %q: %w", adapter.Key(), err)
|
||||
@@ -228,9 +240,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
if chunkResult.rejection != nil {
|
||||
output.Rejected = append(output.Rejected, *chunkResult.rejection)
|
||||
}
|
||||
if chunkResult.accepted || chunkResult.lookup.Status == ChunkPlanHit {
|
||||
output.Warnings = append(output.Warnings, chunkResult.warnings...)
|
||||
}
|
||||
output.Warnings = append(output.Warnings, chunkResult.warnings...)
|
||||
chunkDebugPayload := map[string]any{
|
||||
"cache_mode": chunkMode,
|
||||
"lookup": chunkResult.lookup,
|
||||
@@ -280,6 +290,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
return failOutput(output), err
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
|
||||
if len(output.Rejected) > 0 {
|
||||
output.Manifest.ValidationStatus = "rejected"
|
||||
@@ -293,11 +306,17 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
if err := attachModuleManifestMetadata(&output, "output", encoder); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
outputStarted := time.Now().UTC()
|
||||
evidenceArtifact, evidenceSummary, err := buildOutputEvidenceContext(input.Prepared, doc, output.NormalizeOutputs)
|
||||
if err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
if evidenceSummary != nil {
|
||||
if err := writeDebugTimed(debugRecorder, "output/evidence-context.json", debugTimedEnvelope{
|
||||
Stage: string(StageOutput),
|
||||
@@ -307,6 +326,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
}); err != nil {
|
||||
return failOutput(output), fmt.Errorf("write evidence context debug artifact: %w", err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
}
|
||||
outputDebugPayload := map[string]any{
|
||||
"manifest": output.Manifest,
|
||||
@@ -327,10 +349,16 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
}); err != nil {
|
||||
return failOutput(output), fmt.Errorf("write output debug artifact: %w", err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
outputMetadata, err := cloneMetadata(input.Metadata)
|
||||
if err != nil {
|
||||
return failOutput(output), fmt.Errorf("clone output encoder metadata: %w", err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
encoded, err := encoder.Encode(ctx, contracts.OutputRequest{
|
||||
Manifest: output.Manifest,
|
||||
NormalizeOutputs: cloneSerializedOutputs(output.NormalizeOutputs),
|
||||
@@ -341,7 +369,9 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
ChunkMap: contracts.CloneSerializedArtifactPointer(acceptedChunkMap),
|
||||
EvidenceContext: contracts.CloneSerializedArtifactPointer(evidenceArtifact),
|
||||
})
|
||||
output.Warnings = append(output.Warnings, encoded.Warnings...)
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return failOutput(output), ctxErr
|
||||
}
|
||||
if err != nil {
|
||||
return failOutput(output), fmt.Errorf("encode output with encoder %q: %w", encoder.Key(), err)
|
||||
}
|
||||
@@ -349,7 +379,6 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
if err != nil {
|
||||
return failOutput(output), fmt.Errorf("validate output files from encoder %q: %w", encoder.Key(), err)
|
||||
}
|
||||
output.OutputFiles = files
|
||||
if err := writeDebugTimed(debugRecorder, "output/output.json", debugTimedEnvelope{
|
||||
Stage: string(StageOutput),
|
||||
ModuleKey: encoder.Key(),
|
||||
@@ -361,6 +390,11 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
|
||||
}); err != nil {
|
||||
return failOutput(output), fmt.Errorf("write output debug artifact: %w", err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return failOutput(output), err
|
||||
}
|
||||
output.Warnings = append(output.Warnings, encoded.Warnings...)
|
||||
output.OutputFiles = files
|
||||
|
||||
return output, nil
|
||||
}
|
||||
@@ -386,45 +420,52 @@ func (r *Runner) runPreparedSteps(ctx context.Context, input RunInput, checkpoin
|
||||
return nil
|
||||
}
|
||||
|
||||
func runWithRetry(ctx context.Context, retries int, run func(attempt int) (bool, *contracts.RejectedOutput, error)) (bool, *contracts.RejectedOutput, error) {
|
||||
type retryAttemptResult struct {
|
||||
accepted bool
|
||||
rejection *contracts.RejectedOutput
|
||||
warnings []contracts.Warning
|
||||
}
|
||||
|
||||
func runWithRetry(ctx context.Context, retries int, run func(attempt int) (retryAttemptResult, error)) (retryAttemptResult, error) {
|
||||
attempts := retries + 1
|
||||
var last *contracts.RejectedOutput
|
||||
var last retryAttemptResult
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, nil, err
|
||||
return retryAttemptResult{}, err
|
||||
}
|
||||
accepted, rejection, err := run(attempt)
|
||||
result, err := run(attempt)
|
||||
if err != nil {
|
||||
var debugErr *attemptDebugPersistenceError
|
||||
if errors.As(err, &debugErr) {
|
||||
return false, nil, fmt.Errorf("failed after %d attempt(s): %w", attempt, err)
|
||||
return retryAttemptResult{}, fmt.Errorf("failed after %d attempt(s): %w", attempt, err)
|
||||
}
|
||||
if attempt == attempts {
|
||||
return false, nil, fmt.Errorf("failed after %d attempt(s): %w", attempt, err)
|
||||
return retryAttemptResult{}, fmt.Errorf("failed after %d attempt(s): %w", attempt, err)
|
||||
}
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return false, nil, ctxErr
|
||||
return retryAttemptResult{}, ctxErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if accepted {
|
||||
return true, nil, nil
|
||||
if result.accepted {
|
||||
return result, nil
|
||||
}
|
||||
if rejection != nil {
|
||||
if result.rejection != nil {
|
||||
rejection := *result.rejection
|
||||
rejection.AttemptCount = attempt
|
||||
last = rejection
|
||||
last = retryAttemptResult{rejection: &rejection, warnings: cloneWarnings(result.warnings)}
|
||||
}
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return false, nil, ctxErr
|
||||
return retryAttemptResult{}, ctxErr
|
||||
}
|
||||
if attempt == attempts {
|
||||
if last == nil {
|
||||
last = &contracts.RejectedOutput{ReasonCode: "output_rejected", Message: "output rejected", AttemptCount: attempt}
|
||||
if last.rejection == nil {
|
||||
last.rejection = &contracts.RejectedOutput{ReasonCode: "output_rejected", Message: "output rejected", AttemptCount: attempt}
|
||||
}
|
||||
return false, last, nil
|
||||
return last, nil
|
||||
}
|
||||
}
|
||||
return false, last, nil
|
||||
return last, nil
|
||||
}
|
||||
|
||||
func (r *Runner) validateChunks(ctx context.Context, doc *source.SourceDocument, moduleKey string, chunks []source.Chunk, sourceInput contracts.LLMInputMaterial, sessionID string, references contracts.ReferenceSet, metadata map[string]any, prepared preparedValidatorChain, attempt int, debug DebugRecorder) ([]contracts.Warning, *contracts.RejectedOutput, error) {
|
||||
@@ -437,7 +478,7 @@ func (r *Runner) validateChunks(ctx context.Context, doc *source.SourceDocument,
|
||||
for index, item := range prepared.validators {
|
||||
binding := item.resolved.Binding
|
||||
started := time.Now().UTC()
|
||||
attemptPath := path.Join("validate", debugPathComponent(string(StageChunk)), "", debugPathComponent(moduleKey), fmt.Sprintf("%02d-%s-attempt-%02d", index+1, debugPathComponent(binding.Module), attempt))
|
||||
attemptPath := path.Join("validate", fileio.EncodePathComponent(string(StageChunk)), "", fileio.EncodePathComponent(moduleKey), fmt.Sprintf("%02d-%s-attempt-%02d", index+1, fileio.EncodePathComponent(binding.Module), attempt))
|
||||
validatorCtx, llmScope := withIsolatedDebugLLMScope(ctx, attemptPath)
|
||||
var result contracts.ValidationResult
|
||||
requestMetadata, cloneErr := cloneMetadata(metadata)
|
||||
|
||||
@@ -351,7 +351,7 @@ func TestRunnerKeepsValidatorLLMCallsOutOfModuleAttempt(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v, want nil", err)
|
||||
}
|
||||
moduleEnvelope := debug.envelope(t, "merge/notes/attempt-01.json")
|
||||
validatorPath := "validate/merge/notes/typed~2fmerge/01-llm-check-attempt-01.json"
|
||||
validatorPath := "validate/merge/notes/typed%2Fmerge/01-llm-check-attempt-01.json"
|
||||
validatorEnvelope := debug.envelope(t, validatorPath)
|
||||
if len(moduleEnvelope.LLMCalls) != 1 || !strings.Contains(moduleEnvelope.LLMCalls[0].ResponsePath, "merge/notes/attempt-01/") {
|
||||
t.Fatalf("module LLM calls = %#v, want module call only", moduleEnvelope.LLMCalls)
|
||||
|
||||
@@ -2,6 +2,7 @@ package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -13,10 +14,12 @@ import (
|
||||
)
|
||||
|
||||
type observedNotesCodec struct {
|
||||
candidateValues []codecNotes
|
||||
finalValues []codecNotes
|
||||
candidateError string
|
||||
finalError string
|
||||
candidateValues []codecNotes
|
||||
candidateDecodedValues []codecNotes
|
||||
finalValues []codecNotes
|
||||
candidateError string
|
||||
candidateDecodeError string
|
||||
finalError string
|
||||
}
|
||||
|
||||
func (*observedNotesCodec) Kind() contracts.ArtifactKind { return "test/notes" }
|
||||
@@ -49,6 +52,18 @@ func (*observedNotesCodec) Decode(content []byte) (codecNotes, error) {
|
||||
return value, json.Unmarshal(content, &value)
|
||||
}
|
||||
|
||||
func (c *observedNotesCodec) DecodeCandidate(content []byte) (codecNotes, error) {
|
||||
value, err := c.Decode(content)
|
||||
if err != nil {
|
||||
return codecNotes{}, err
|
||||
}
|
||||
if c.candidateDecodeError != "" && firstNote(value) == c.candidateDecodeError {
|
||||
return codecNotes{}, errors.New("candidate decoding failed")
|
||||
}
|
||||
c.candidateDecodedValues = append(c.candidateDecodedValues, value)
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func firstNote(value codecNotes) string {
|
||||
if len(value.Items) == 0 {
|
||||
return ""
|
||||
@@ -64,6 +79,10 @@ func (c *observedNotesCodec) finalCalls(value string) int {
|
||||
return matchingNotes(c.finalValues, value)
|
||||
}
|
||||
|
||||
func (c *observedNotesCodec) candidateDecodeCount() int {
|
||||
return len(c.candidateDecodedValues)
|
||||
}
|
||||
|
||||
func matchingNotes(values []codecNotes, value string) int {
|
||||
count := 0
|
||||
for _, candidate := range values {
|
||||
@@ -110,6 +129,16 @@ func installObservedNotesCodec(t *testing.T, prepared *PreparedPipeline, codec *
|
||||
func configureCandidateOperation(prepared *PreparedPipeline, target ModuleStage, value codecNotes) {
|
||||
lane := &prepared.Steps[0].lanes[0]
|
||||
switch target {
|
||||
case StageExtract:
|
||||
lane.typed.extract = func(context.Context, any, contracts.TypedExtractionRequest) (erasedTypedResult, error) {
|
||||
return erasedTypedResult{Value: value}, nil
|
||||
}
|
||||
lane.typed.merge = func(context.Context, any, contracts.TypedMergeRequest[any]) (erasedTypedResult, error) {
|
||||
return erasedTypedResult{Value: codecNotes{Items: []string{"merged-other"}}}, nil
|
||||
}
|
||||
lane.typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
|
||||
return erasedTypedResult{Value: codecNotes{Items: []string{"normalized-other"}}}, nil
|
||||
}
|
||||
case StageMerge:
|
||||
lane.typed.merge = func(context.Context, any, contracts.TypedMergeRequest[any]) (erasedTypedResult, error) {
|
||||
return erasedTypedResult{Value: value}, nil
|
||||
@@ -135,6 +164,8 @@ func setCandidateValidator(prepared *PreparedPipeline, target ModuleStage, appro
|
||||
},
|
||||
}
|
||||
switch target {
|
||||
case StageExtract:
|
||||
prepared.Steps[0].lanes[0].extractValidators.validators = []preparedValidator{validator}
|
||||
case StageMerge:
|
||||
prepared.Steps[0].lanes[0].mergeValidators.validators = []preparedValidator{validator}
|
||||
case StageNormalize:
|
||||
@@ -142,6 +173,135 @@ func setCandidateValidator(prepared *PreparedPipeline, target ModuleStage, appro
|
||||
}
|
||||
}
|
||||
|
||||
type serializedValidationFunc func(context.Context, contracts.SerializedValidationRequest) (contracts.ValidationResult, error)
|
||||
|
||||
func (serializedValidationFunc) Name() string { return "candidate-serialized" }
|
||||
func (serializedValidationFunc) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
}
|
||||
func (validate serializedValidationFunc) Validate(ctx context.Context, request contracts.SerializedValidationRequest) (contracts.ValidationResult, error) {
|
||||
return validate(ctx, request)
|
||||
}
|
||||
|
||||
func TestRunnerIsolatesTypedValidatorCandidates(t *testing.T) {
|
||||
for _, target := range []ModuleStage{StageExtract, StageMerge, StageNormalize} {
|
||||
t.Run(string(target), func(t *testing.T) {
|
||||
prepared := preparedAttemptDebugPipeline(t)
|
||||
prepared.Steps[0].lanes[0].extractValidators = preparedValidatorChain{}
|
||||
prepared.Steps[0].lanes[0].mergeValidators = preparedValidatorChain{}
|
||||
prepared.Steps[0].lanes[0].normalizeValidators = preparedValidatorChain{}
|
||||
codec := &observedNotesCodec{}
|
||||
installObservedNotesCodec(t, prepared, codec)
|
||||
candidate := codecNotes{Items: []string{"candidate-" + string(target)}, Labels: map[string]string{"label": "original"}, Details: &codecNoteDetails{Name: "original"}}
|
||||
configureCandidateOperation(prepared, target, candidate)
|
||||
|
||||
var laterValue, serializedValue, downstreamValue codecNotes
|
||||
firstValidator := preparedValidator{
|
||||
resolved: ResolvedValidator{Binding: Binding("candidate-mutator"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"},
|
||||
typedValidate: func(_ context.Context, _ any, request typedValidationTarget) (contracts.ValidationResult, error) {
|
||||
value := request.value.(codecNotes)
|
||||
value.Items[0] = "mutated"
|
||||
value.Labels["label"] = "mutated"
|
||||
value.Details.Name = "mutated"
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
},
|
||||
}
|
||||
secondValidator := preparedValidator{
|
||||
resolved: ResolvedValidator{Binding: Binding("candidate-observer"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"},
|
||||
typedValidate: func(_ context.Context, _ any, request typedValidationTarget) (contracts.ValidationResult, error) {
|
||||
laterValue = request.value.(codecNotes)
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
},
|
||||
}
|
||||
serializedValidator := preparedValidator{
|
||||
resolved: ResolvedValidator{Binding: Binding("candidate-serialized"), Target: ValidatorTargetSerialized, ArtifactKind: "test/notes"},
|
||||
serialized: serializedValidationFunc(func(_ context.Context, request contracts.SerializedValidationRequest) (contracts.ValidationResult, error) {
|
||||
if err := json.Unmarshal(request.Content, &serializedValue); err != nil {
|
||||
return contracts.ValidationResult{}, err
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}),
|
||||
}
|
||||
setCandidateValidators(prepared, target, []preparedValidator{firstValidator, secondValidator, serializedValidator})
|
||||
|
||||
lane := &prepared.Steps[0].lanes[0]
|
||||
switch target {
|
||||
case StageExtract:
|
||||
lane.typed.merge = func(_ context.Context, _ any, request contracts.TypedMergeRequest[any]) (erasedTypedResult, error) {
|
||||
downstreamValue = request.ExtractOutputs[0].Value.(codecNotes)
|
||||
return erasedTypedResult{Value: codecNotes{Items: []string{"merged-other"}}}, nil
|
||||
}
|
||||
case StageMerge:
|
||||
lane.typed.normalize = func(_ context.Context, _ any, request contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
|
||||
downstreamValue = request.MergeOutput.Value.(codecNotes)
|
||||
return erasedTypedResult{Value: codecNotes{Items: []string{"normalized-other"}}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
debug := newCapturedDebugRecorder()
|
||||
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Debug: debug})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v, want nil", err)
|
||||
}
|
||||
if !reflect.DeepEqual(laterValue, candidate) || !reflect.DeepEqual(serializedValue, candidate) {
|
||||
t.Fatalf("validator values = typed %#v serialized %#v, want %#v", laterValue, serializedValue, candidate)
|
||||
}
|
||||
if codec.candidateCalls(candidate.Items[0]) != 1 || codec.candidateDecodeCount() != 2 || codec.finalCalls(candidate.Items[0]) != 1 {
|
||||
t.Fatalf("candidate calls = encode %d decode %d final %d, want 1, 2, 1", codec.candidateCalls(candidate.Items[0]), codec.candidateDecodeCount(), codec.finalCalls(candidate.Items[0]))
|
||||
}
|
||||
switch target {
|
||||
case StageExtract, StageMerge:
|
||||
if !reflect.DeepEqual(downstreamValue, candidate) {
|
||||
t.Fatalf("downstream value = %#v, want %#v", downstreamValue, candidate)
|
||||
}
|
||||
case StageNormalize:
|
||||
var normalized codecNotes
|
||||
if err := json.Unmarshal(output.NormalizeOutputs[0].Artifact.Content, &normalized); err != nil || !reflect.DeepEqual(normalized, candidate) {
|
||||
t.Fatalf("normalized output = %#v, %v; want %#v", normalized, err, candidate)
|
||||
}
|
||||
}
|
||||
attemptPath := fmt.Sprintf("%s/notes/attempt-01.json", target)
|
||||
if target == StageExtract {
|
||||
attemptPath = "extract/notes/chunk-000001/attempt-01.json"
|
||||
}
|
||||
payload := debug.envelope(t, attemptPath).Payload.(map[string]any)
|
||||
content, err := base64.StdEncoding.DecodeString(payload["output"].(map[string]any)["content"].(map[string]any)["content_base64"].(string))
|
||||
var debugValue codecNotes
|
||||
if err == nil {
|
||||
err = json.Unmarshal(content, &debugValue)
|
||||
}
|
||||
if err != nil || !reflect.DeepEqual(debugValue, candidate) {
|
||||
t.Fatalf("debug candidate = %#v, %v; want %#v", debugValue, err, candidate)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setCandidateValidators(prepared *PreparedPipeline, target ModuleStage, validators []preparedValidator) {
|
||||
switch target {
|
||||
case StageExtract:
|
||||
prepared.Steps[0].lanes[0].extractValidators.validators = validators
|
||||
case StageMerge:
|
||||
prepared.Steps[0].lanes[0].mergeValidators.validators = validators
|
||||
case StageNormalize:
|
||||
prepared.Steps[0].lanes[0].normalizeValidators.validators = validators
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerReportsCandidateDecodeFailure(t *testing.T) {
|
||||
prepared := preparedAttemptDebugPipeline(t)
|
||||
codec := &observedNotesCodec{candidateDecodeError: "decode-failure"}
|
||||
installObservedNotesCodec(t, prepared, codec)
|
||||
configureCandidateOperation(prepared, StageMerge, codecNotes{Items: []string{"decode-failure"}})
|
||||
setCandidateValidator(prepared, StageMerge, true)
|
||||
|
||||
_, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input")})
|
||||
var codecErr *ArtifactCodecOperationError
|
||||
if err == nil || !errors.As(err, &codecErr) || codecErr.Operation != "decode candidate" || codec.finalCalls("decode-failure") != 0 {
|
||||
t.Fatalf("Run() error = %v, codec error = %#v, final calls = %d; want contextual candidate decode failure", err, codecErr, codec.finalCalls("decode-failure"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRejectsCandidatesBeforeFinalEncoding(t *testing.T) {
|
||||
for _, target := range []ModuleStage{StageMerge, StageNormalize} {
|
||||
t.Run(string(target), func(t *testing.T) {
|
||||
|
||||
@@ -84,14 +84,14 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
|
||||
}
|
||||
|
||||
var producerWarnings []contracts.Warning
|
||||
accepted, rejection, err := runWithRetry(ctx, input.pipeline.Chunk.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) {
|
||||
retryResult, err := runWithRetry(ctx, input.pipeline.Chunk.Retries, func(attempt int) (retryAttemptResult, error) {
|
||||
attemptStarted := time.Now().UTC()
|
||||
attemptPath := path.Join("chunk", fmt.Sprintf("attempt-%02d", attempt))
|
||||
attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath)
|
||||
terminal := newAttemptTerminalRecorder(input.Debug, attemptPath, "chunk", llmScope, debugTimedEnvelope{Stage: string(StageChunk), ModuleKey: chunker.Key(), Attempt: attempt, StartedAt: attemptStarted})
|
||||
requestMetadata, metadataErr := cloneMetadata(input.Metadata)
|
||||
if metadataErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("clone chunk request metadata: %w", metadataErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone chunk request metadata: %w", metadataErr))
|
||||
}
|
||||
chunkResult, callErr := chunker.Plan(attemptCtx, contracts.ChunkRequest{
|
||||
Source: doc, SourceInput: sourceInput.Clone(), SessionID: sessionID,
|
||||
@@ -99,21 +99,21 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
|
||||
LLMProfile: input.pipeline.Chunk.LLMProfile, Metadata: requestMetadata,
|
||||
})
|
||||
if callErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("chunk source with chunker %q: %w", chunker.Key(), callErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("chunk source with chunker %q: %w", chunker.Key(), callErr))
|
||||
}
|
||||
plan, chunks, validationErr := validateAndMaterializeChunkPlan(doc, chunkResult.Plan)
|
||||
if validationErr != nil {
|
||||
attemptErr := fmt.Errorf("validate chunk plan from chunker %q: %w", chunker.Key(), validationErr)
|
||||
payload := map[string]any{"plan": debugChunkPlanEnvelope(chunkResult.Plan), "warnings": debugWarningEnvelopes(chunkResult.Warnings)}
|
||||
return false, nil, terminal.record(payload, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(payload, attemptErr)
|
||||
}
|
||||
planDigest, digestErr := source.DigestChunkPlan(plan)
|
||||
if digestErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("digest generated chunk plan: %w", digestErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("digest generated chunk plan: %w", digestErr))
|
||||
}
|
||||
producerMetadata, _, metadataErr := moduleManifestMetadata(chunker)
|
||||
if metadataErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("clone chunker manifest metadata: %w", metadataErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone chunker manifest metadata: %w", metadataErr))
|
||||
}
|
||||
profile := ""
|
||||
if input.pipeline.ChunkExecutionClass == contracts.ExecutionClassLLMBacked {
|
||||
@@ -137,7 +137,7 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
|
||||
action = "bypassed"
|
||||
}
|
||||
if candidateErr := result.setCandidate(candidate, action); candidateErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("clone generated chunk plan record: %w", candidateErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone generated chunk plan record: %w", candidateErr))
|
||||
}
|
||||
validationWarnings, rejected, validationErr := r.validateChunks(attemptCtx, doc, chunker.Key(), chunks, sourceInput, sessionID, input.pipeline.ChunkReferences.ReferenceSet, input.Metadata, input.Prepared.chunkValidators, attempt, input.Debug)
|
||||
attemptWarnings := append(cloneWarnings(chunkResult.Warnings), validationWarnings...)
|
||||
@@ -145,9 +145,16 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
|
||||
"plan": debugChunkPlanEnvelope(plan), "materialized_chunks": debugSourceChunkEnvelopes(chunks),
|
||||
"warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected),
|
||||
}
|
||||
if validationErr != nil || rejected != nil {
|
||||
if validationErr != nil {
|
||||
result.setValidation(validationWarnings, rejected, validationErr)
|
||||
return false, rejected, terminal.record(payload, validationErr)
|
||||
return retryAttemptResult{}, terminal.record(payload, validationErr)
|
||||
}
|
||||
if rejected != nil {
|
||||
result.setValidation(validationWarnings, rejected, nil)
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
return retryAttemptResult{rejection: rejected, warnings: attemptWarnings}, nil
|
||||
}
|
||||
result.chunks = chunks
|
||||
result.plan = &plan
|
||||
@@ -155,9 +162,9 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
|
||||
producerWarnings = cloneWarnings(chunkResult.Warnings)
|
||||
result.setValidation(validationWarnings, nil, nil)
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return false, nil, debugErr
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
return true, nil, nil
|
||||
return retryAttemptResult{accepted: true}, nil
|
||||
})
|
||||
if err != nil {
|
||||
if result.summary.ValidationStatus == "not_run" {
|
||||
@@ -165,9 +172,10 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
result.accepted = accepted
|
||||
result.rejection = rejection
|
||||
if !accepted {
|
||||
result.accepted = retryResult.accepted
|
||||
result.rejection = retryResult.rejection
|
||||
if !retryResult.accepted {
|
||||
result.warnings = cloneWarnings(retryResult.warnings)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,96 @@ func (a *countingInputAdapter) Parse(ctx context.Context, request contracts.Pars
|
||||
return a.InputAdapter.Parse(ctx, request)
|
||||
}
|
||||
|
||||
type cancelingDebugRecorder struct {
|
||||
cancel context.CancelFunc
|
||||
onName string
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (r *cancelingDebugRecorder) Enabled() bool { return true }
|
||||
|
||||
func (r *cancelingDebugRecorder) WriteJSON(name string, _ any) error {
|
||||
if name == r.onName {
|
||||
r.once.Do(r.cancel)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*cancelingDebugRecorder) WriteBytes(string, []byte) error { return nil }
|
||||
|
||||
type cancelingOutputEncoder struct {
|
||||
contracts.OutputEncoder
|
||||
cancel context.CancelFunc
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (e *cancelingOutputEncoder) Encode(context.Context, contracts.OutputRequest) (contracts.OutputResult, error) {
|
||||
e.calls.Add(1)
|
||||
e.cancel()
|
||||
return contracts.OutputResult{
|
||||
Files: []contracts.OutputFile{{Name: "result.txt", ContentType: "text/plain", Bytes: []byte("result")}},
|
||||
Warnings: []contracts.Warning{{ReasonCode: "returned-after-cancel"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestRunnerSkipsInputAdapterWhenContextIsAlreadyCanceled(t *testing.T) {
|
||||
prepared := preparedConcurrentPipeline(t, 1)
|
||||
adapter := &countingInputAdapter{InputAdapter: prepared.input}
|
||||
prepared.input = adapter
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := New().Run(ctx, RunInput{Prepared: prepared, RawInput: []byte("input")})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run() error = %v, want context.Canceled", err)
|
||||
}
|
||||
if got := adapter.calls.Load(); got != 0 {
|
||||
t.Fatalf("input Parse calls = %d, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerSkipsOutputEncodingAfterDebugCancellation(t *testing.T) {
|
||||
prepared := preparedConcurrentPipeline(t, 1)
|
||||
encoder := &countingOrderedOutput{}
|
||||
prepared.output = encoder
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
_, err := New().Run(ctx, RunInput{
|
||||
Prepared: prepared,
|
||||
RawInput: []byte("input"),
|
||||
Debug: &cancelingDebugRecorder{cancel: cancel, onName: "output/input.json"},
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run() error = %v, want context.Canceled", err)
|
||||
}
|
||||
if got := encoder.calls.Load(); got != 0 {
|
||||
t.Fatalf("output Encode calls = %d, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerDiscardsOutputReturnedAfterCancellation(t *testing.T) {
|
||||
prepared := preparedConcurrentPipeline(t, 1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
encoder := &cancelingOutputEncoder{OutputEncoder: prepared.output, cancel: cancel}
|
||||
prepared.output = encoder
|
||||
|
||||
output, err := New().Run(ctx, RunInput{Prepared: prepared, RawInput: []byte("input")})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run() error = %v, want context.Canceled", err)
|
||||
}
|
||||
if got := encoder.calls.Load(); got != 1 {
|
||||
t.Fatalf("output Encode calls = %d, want one", got)
|
||||
}
|
||||
if len(output.OutputFiles) != 0 {
|
||||
t.Fatalf("output files = %#v, want none", output.OutputFiles)
|
||||
}
|
||||
for _, warning := range output.Warnings {
|
||||
if warning.ReasonCode == "returned-after-cancel" {
|
||||
t.Fatalf("output warnings include encoder warning after cancellation")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerFailsGeneratedHandoffBeforeConsumerExtraction(t *testing.T) {
|
||||
prepared := preparedConcurrentPipeline(t, 1)
|
||||
input := &countingInputAdapter{InputAdapter: prepared.input}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
@@ -253,6 +254,9 @@ func (e *laneEngine) startContinuationWorkers() {
|
||||
}
|
||||
}
|
||||
|
||||
// collect is the sole owner of pending, launch, and completion accounting. It
|
||||
// must drain closed extract results and one completion for every launched
|
||||
// continuation, even after cancellation.
|
||||
func (e *laneEngine) collect() {
|
||||
resultChannel := (<-chan extractJobResult)(e.results)
|
||||
for resultChannel != nil || len(e.pending) > 0 || e.completed < e.launched {
|
||||
@@ -406,20 +410,20 @@ func (r *Runner) runExtractJob(ctx context.Context, input RunInput, doc *source.
|
||||
var accepted erasedExtractArtifact
|
||||
var serialized CheckpointArtifact
|
||||
var acceptedWarnings []contracts.Warning
|
||||
ok, rejection, err := runWithRetry(ctx, lane.Extract.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) {
|
||||
retryResult, err := runWithRetry(ctx, lane.Extract.Retries, func(attempt int) (retryAttemptResult, error) {
|
||||
started := time.Now().UTC()
|
||||
attemptPath := path.Join("extract", debugPathComponent(lane.ID), fmt.Sprintf("chunk-%06d", chunk.Index+1), fmt.Sprintf("attempt-%02d", attempt))
|
||||
attemptPath := path.Join("extract", fileio.EncodePathComponent(lane.ID), fmt.Sprintf("chunk-%06d", chunk.Index+1), fmt.Sprintf("attempt-%02d", attempt))
|
||||
attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath)
|
||||
terminal := newAttemptTerminalRecorder(input.Debug, attemptPath, "extract", llmScope, debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, Attempt: attempt, StartedAt: started})
|
||||
requestMetadata, metadataErr := cloneMetadata(input.Metadata)
|
||||
if metadataErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("clone extract request metadata: %w", metadataErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone extract request metadata: %w", metadataErr))
|
||||
}
|
||||
extractReferences := operationReferenceSet(input, lane.ExtractReferences)
|
||||
extracted, callErr := typed.extract(attemptCtx, typed.extractor, contracts.TypedExtractionRequest{Source: doc, Chunk: &chunk, SourceInput: chunkInputMaterial(sourceInput, chunk), SessionID: sessionID, References: CloneReferenceSet(extractReferences), LLMProfile: lane.Extract.LLMProfile, Metadata: requestMetadata})
|
||||
if callErr != nil {
|
||||
attemptErr := fmt.Errorf("extract lane %q chunk %q with extractor %q: %w", lane.ID, chunk.ID, lane.Extract.Module, callErr)
|
||||
return false, nil, terminal.record(nil, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(nil, attemptErr)
|
||||
}
|
||||
artifact := erasedExtractArtifact{LaneID: lane.ID, ExtractorKey: lane.Extract.Module, SourceID: doc.ID, ChunkID: chunk.ID, ChunkIndex: chunk.Index, ChunkRef: chunk.Ref, Value: extracted.Value}
|
||||
attemptWarnings := cloneWarnings(extracted.Warnings)
|
||||
@@ -427,31 +431,38 @@ func (r *Runner) runExtractJob(ctx context.Context, input RunInput, doc *source.
|
||||
if encodeErr != nil {
|
||||
attemptErr := fmt.Errorf("serialize extract candidate for lane %q chunk %q: %w", lane.ID, chunk.ID, encodeErr)
|
||||
payload := map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}
|
||||
return false, nil, terminal.record(payload, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(payload, attemptErr)
|
||||
}
|
||||
serializedCandidate.ChunkID, serializedCandidate.ChunkIndex, serializedCandidate.ChunkRef = artifact.ChunkID, artifact.ChunkIndex, artifact.ChunkRef
|
||||
warnings, rejected, validateErr := r.validateTypedArtifact(attemptCtx, typed.codec, typedValidationTarget{stage: StageExtract, stepID: input.stepID, laneID: lane.ID, moduleKey: lane.Extract.Module, source: doc, sourceID: doc.ID, sourceInput: chunkInputMaterial(sourceInput, chunk), sessionID: sessionID, references: extractReferences, metadata: input.Metadata, chunk: &chunk, ref: chunk.Ref, value: extracted.Value, candidate: &serializedCandidate}, state.prepared.extractValidators, attempt, input.Debug)
|
||||
attemptWarnings = append(attemptWarnings, warnings...)
|
||||
payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected)}
|
||||
if validateErr != nil || rejected != nil {
|
||||
return false, rejected, terminal.record(payload, validateErr)
|
||||
if validateErr != nil {
|
||||
return retryAttemptResult{}, terminal.record(payload, validateErr)
|
||||
}
|
||||
if rejected != nil {
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
return retryAttemptResult{rejection: rejected, warnings: attemptWarnings}, nil
|
||||
}
|
||||
stored, encodeErr := checkpointArtifact(typed.codec, artifact.LaneID, artifact.ExtractorKey, artifact.SourceID, artifact.Value)
|
||||
if encodeErr != nil {
|
||||
attemptErr := fmt.Errorf("serialize accepted extract output for lane %q chunk %q: %w", lane.ID, chunk.ID, encodeErr)
|
||||
return false, nil, terminal.record(payload, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(payload, attemptErr)
|
||||
}
|
||||
stored.ChunkID, stored.ChunkIndex, stored.ChunkRef = artifact.ChunkID, artifact.ChunkIndex, artifact.ChunkRef
|
||||
accepted, serialized = artifact, stored
|
||||
acceptedWarnings = attemptWarnings
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return false, nil, debugErr
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
return true, nil, nil
|
||||
return retryAttemptResult{accepted: true}, nil
|
||||
})
|
||||
result.err = err
|
||||
if err == nil && !ok {
|
||||
result.rejected = rejection
|
||||
if err == nil && !retryResult.accepted {
|
||||
result.rejected = retryResult.rejection
|
||||
result.warnings = cloneWarnings(retryResult.warnings)
|
||||
return result
|
||||
}
|
||||
result.value, result.serialized, result.warnings = accepted, serialized, acceptedWarnings
|
||||
@@ -469,6 +480,7 @@ func finalizeLaneExtract(checkpoints CheckpointRecorder, stepID string, state *l
|
||||
result := state.results[index]
|
||||
if result.rejected != nil {
|
||||
state.rejected = append(state.rejected, *result.rejected)
|
||||
state.warnings = append(state.warnings, result.warnings...)
|
||||
continue
|
||||
}
|
||||
state.values = append(state.values, result.value)
|
||||
@@ -498,10 +510,10 @@ func (r *Runner) continueLane(ctx context.Context, input RunInput, checkpoints C
|
||||
}
|
||||
local.Warnings = append(local.Warnings, cloneWarnings(results.warnings)...)
|
||||
local.Rejected = append(local.Rejected, cloneRejectedOutputs(results.rejected)...)
|
||||
if err := writeDebugTimed(input.Debug, path.Join("extract", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": results.decision.Reused, "decision": results.decision, "source": debugSourceDocumentEnvelope(doc), "chunks": debugSourceChunkEnvelopes(chunks), "options": redactSensitiveMap(lane.Extract.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
|
||||
if err := writeDebugTimed(input.Debug, path.Join("extract", fileio.EncodePathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": results.decision.Reused, "decision": results.decision, "source": debugSourceDocumentEnvelope(doc), "chunks": debugSourceChunkEnvelopes(chunks), "options": redactSensitiveMap(lane.Extract.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
|
||||
return local, &laneRunError{stage: StageExtract, err: err}
|
||||
}
|
||||
if err := writeDebugTimed(input.Debug, path.Join("extract", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": results.decision.Reused, "outputs": debugCheckpointArtifacts(results.serialized), "rejected": debugRejectedOutputEnvelopes(results.rejected), "warnings": debugWarningEnvelopes(results.warnings)}}); err != nil {
|
||||
if err := writeDebugTimed(input.Debug, path.Join("extract", fileio.EncodePathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": results.decision.Reused, "outputs": debugCheckpointArtifacts(results.serialized), "rejected": debugRejectedOutputEnvelopes(results.rejected), "warnings": debugWarningEnvelopes(results.warnings)}}); err != nil {
|
||||
return local, &laneRunError{stage: StageExtract, err: err}
|
||||
}
|
||||
if len(results.accepted) == 0 {
|
||||
|
||||
142
internal/framework/pipeline/runner_rejection_warnings_test.go
Normal file
142
internal/framework/pipeline/runner_rejection_warnings_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
type warningChunker struct {
|
||||
key string
|
||||
plan source.ChunkPlan
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *warningChunker) Key() string { return c.key }
|
||||
func (*warningChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
func (c *warningChunker) Plan(context.Context, contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
|
||||
c.calls++
|
||||
return contracts.ChunkPlanResult{
|
||||
Plan: source.CloneChunkPlan(c.plan),
|
||||
Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", c.calls), ReasonCode: "operation", Message: "operation warning"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type chunkValidationFunc struct {
|
||||
name string
|
||||
validate func(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error)
|
||||
}
|
||||
|
||||
func (v chunkValidationFunc) Name() string { return v.name }
|
||||
func (chunkValidationFunc) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
}
|
||||
func (v chunkValidationFunc) Validate(ctx context.Context, request contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
|
||||
return v.validate(ctx, request)
|
||||
}
|
||||
|
||||
func TestRunnerPromotesOnlyTerminalRejectionWarnings(t *testing.T) {
|
||||
for _, target := range []ModuleStage{StageChunk, StageExtract, StageMerge, StageNormalize} {
|
||||
t.Run(string(target), func(t *testing.T) {
|
||||
prepared := preparedAttemptDebugPipeline(t)
|
||||
lane := &prepared.Steps[0].lanes[0]
|
||||
attempts := 0
|
||||
first := func() contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: true, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("validator-%d", attempts), ReasonCode: "validator", Message: "validator warning"}}}
|
||||
}
|
||||
reject := func() contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "rejected"}
|
||||
}
|
||||
debug := newCapturedDebugRecorder()
|
||||
recorder := &extractCaptureRecorder{CheckpointRecorder: NoopCheckpointRecorder()}
|
||||
|
||||
switch target {
|
||||
case StageChunk:
|
||||
chunker := prepared.chunker.(*typedTestChunker)
|
||||
prepared.chunker = &warningChunker{key: prepared.resolved.Chunk.Module, plan: source.CloneChunkPlan(chunker.plan)}
|
||||
prepared.resolved.Chunk.Retries = 1
|
||||
prepared.chunkValidators.validators = []preparedValidator{
|
||||
{resolved: ResolvedValidator{Binding: Binding("warning-approval"), Target: ValidatorTargetChunk}, chunk: chunkValidationFunc{name: "warning-approval", validate: func(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
|
||||
return first(), nil
|
||||
}}},
|
||||
{resolved: ResolvedValidator{Binding: Binding("warning-rejection"), Target: ValidatorTargetChunk}, chunk: chunkValidationFunc{name: "warning-rejection", validate: func(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
|
||||
return reject(), nil
|
||||
}}},
|
||||
}
|
||||
chunkerWithWarnings := prepared.chunker.(*warningChunker)
|
||||
first = func() contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: true, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("validator-%d", chunkerWithWarnings.calls), ReasonCode: "validator", Message: "validator warning"}}}
|
||||
}
|
||||
case StageExtract:
|
||||
lane.resolved.Extract.Retries = 1
|
||||
installExtractOperation(prepared, 0, func(context.Context, contracts.TypedExtractionRequest) (erasedTypedResult, error) {
|
||||
attempts++
|
||||
return erasedTypedResult{Value: codecNotes{Items: []string{"extract"}}, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", attempts), ReasonCode: "operation", Message: "operation warning"}}}, nil
|
||||
})
|
||||
lane.extractValidators.validators = rejectionWarningTypedValidators(first, reject)
|
||||
case StageMerge:
|
||||
lane.resolved.Merge.Retries = 1
|
||||
lane.typed.merge = func(context.Context, any, contracts.TypedMergeRequest[any]) (erasedTypedResult, error) {
|
||||
attempts++
|
||||
return erasedTypedResult{Value: codecNotes{Items: []string{"merge"}}, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", attempts), ReasonCode: "operation", Message: "operation warning"}}}, nil
|
||||
}
|
||||
lane.mergeValidators.validators = rejectionWarningTypedValidators(first, reject)
|
||||
case StageNormalize:
|
||||
lane.resolved.Normalize.Retries = 1
|
||||
lane.typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
|
||||
attempts++
|
||||
return erasedTypedResult{Value: codecNotes{Items: []string{"normalize"}}, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", attempts), ReasonCode: "operation", Message: "operation warning"}}}, nil
|
||||
}
|
||||
lane.normalizeValidators.validators = rejectionWarningTypedValidators(first, reject)
|
||||
}
|
||||
|
||||
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Checkpoints: recorder, Debug: debug})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v, want nil", err)
|
||||
}
|
||||
wantScopes := []string{"operation-2", "validator-2"}
|
||||
if got := rejectionWarningScopes(output.Warnings); !reflect.DeepEqual(got, wantScopes) {
|
||||
t.Fatalf("published warning scopes = %#v, want %#v", got, wantScopes)
|
||||
}
|
||||
if len(output.Rejected) != 1 || output.Rejected[0].AttemptCount != 2 {
|
||||
t.Fatalf("rejections = %#v, want final rejection after two attempts", output.Rejected)
|
||||
}
|
||||
if target == StageExtract && !reflect.DeepEqual(rejectionWarningScopes(recorder.checkpoint.Warnings), wantScopes) {
|
||||
t.Fatalf("extract checkpoint warnings = %#v, want %#v", recorder.checkpoint.Warnings, wantScopes)
|
||||
}
|
||||
attemptPath := fmt.Sprintf("%s/notes/attempt-01.json", target)
|
||||
if target == StageChunk {
|
||||
attemptPath = "chunk/attempt-01.json"
|
||||
} else if target == StageExtract {
|
||||
attemptPath = "extract/notes/chunk-000001/attempt-01.json"
|
||||
}
|
||||
if !strings.Contains(string(debug.json[attemptPath]), "operation-1") || !strings.Contains(string(debug.json[attemptPath]), "validator-1") {
|
||||
t.Fatalf("first attempt debug = %s, want discarded warnings", debug.json[attemptPath])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func rejectionWarningTypedValidators(first func() contracts.ValidationResult, reject func() contracts.ValidationResult) []preparedValidator {
|
||||
return []preparedValidator{
|
||||
{resolved: ResolvedValidator{Binding: Binding("warning-approval"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"}, typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) {
|
||||
return first(), nil
|
||||
}},
|
||||
{resolved: ResolvedValidator{Binding: Binding("warning-rejection"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"}, typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) {
|
||||
return reject(), nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func rejectionWarningScopes(warnings []contracts.Warning) []string {
|
||||
scopes := make([]string, len(warnings))
|
||||
for index := range warnings {
|
||||
scopes[index] = warnings[index].Scope
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
@@ -329,7 +329,7 @@ func TestRunnerKeepsExtractModuleAndValidatorLLMCallsIsolated(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v, want nil", err)
|
||||
}
|
||||
module := debug.envelope(t, "extract/notes/chunk-000001/attempt-01.json")
|
||||
validator := debug.envelope(t, "validate/extract/notes/typed~2fextract-notes/01-llm-check-attempt-01.json")
|
||||
validator := debug.envelope(t, "validate/extract/notes/typed%2Fextract-notes/01-llm-check-attempt-01.json")
|
||||
if len(module.LLMCalls) != 1 || !strings.Contains(module.LLMCalls[0].ResponsePath, "extract/notes/chunk-000001/attempt-01/") {
|
||||
t.Fatalf("module LLM calls = %#v, want extract module call only", module.LLMCalls)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
@@ -232,7 +234,7 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints
|
||||
return stageResult, err
|
||||
}
|
||||
mergeDecision = mergeResolution.decision
|
||||
if err := writeDebugTimed(input.Debug, path.Join("merge", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "decision": mergeDecision, "source": debugSourceDocumentEnvelope(doc), "extract_outputs": debugCheckpointArtifacts(extracts.serialized), "options": redactSensitiveMap(lane.Merge.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
|
||||
if err := writeDebugTimed(input.Debug, path.Join("merge", fileio.EncodePathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "decision": mergeDecision, "source": debugSourceDocumentEnvelope(doc), "extract_outputs": debugCheckpointArtifacts(extracts.serialized), "options": redactSensitiveMap(lane.Merge.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
var merged erasedMergeArtifact
|
||||
@@ -247,52 +249,59 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints
|
||||
if err := checkpointMergeRunning(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
ok, rejection, runErr := runWithRetry(ctx, lane.Merge.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) {
|
||||
retryResult, runErr := runWithRetry(ctx, lane.Merge.Retries, func(attempt int) (retryAttemptResult, error) {
|
||||
started := time.Now().UTC()
|
||||
attemptPath := path.Join("merge", debugPathComponent(lane.ID), fmt.Sprintf("attempt-%02d", attempt))
|
||||
attemptPath := path.Join("merge", fileio.EncodePathComponent(lane.ID), fmt.Sprintf("attempt-%02d", attempt))
|
||||
attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath)
|
||||
terminal := newAttemptTerminalRecorder(input.Debug, attemptPath, "merge", llmScope, debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, Attempt: attempt, StartedAt: started})
|
||||
requestMetadata, metadataErr := cloneMetadata(input.Metadata)
|
||||
if metadataErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("clone merge request metadata: %w", metadataErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone merge request metadata: %w", metadataErr))
|
||||
}
|
||||
result, callErr := typed.merge(attemptCtx, typed.merger, contracts.TypedMergeRequest[any]{Source: doc, LaneID: lane.ID, ExtractOutputs: mergeInputs, SourceInput: sourceInput.Clone(), SessionID: sessionID, References: CloneReferenceSet(mergeReferences), LLMProfile: lane.Merge.LLMProfile, Metadata: requestMetadata})
|
||||
if callErr != nil {
|
||||
attemptErr := fmt.Errorf("merge lane %q with merger %q: %w", lane.ID, lane.Merge.Module, callErr)
|
||||
return false, nil, terminal.record(nil, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(nil, attemptErr)
|
||||
}
|
||||
candidate := erasedMergeArtifact{LaneID: lane.ID, MergerKey: lane.Merge.Module, SourceID: doc.ID, Value: result.Value}
|
||||
attemptWarnings := cloneWarnings(result.Warnings)
|
||||
serializedCandidate, encodeErr := serializeCandidateArtifact(typed.codec, candidate.LaneID, candidate.MergerKey, candidate.SourceID, candidate.Value)
|
||||
if encodeErr != nil {
|
||||
attemptErr := fmt.Errorf("serialize merge candidate for lane %q: %w", lane.ID, encodeErr)
|
||||
return false, nil, terminal.record(map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}, attemptErr)
|
||||
}
|
||||
warnings, rejected, validateErr := r.validateTypedArtifact(attemptCtx, typed.codec, typedValidationTarget{stage: StageMerge, stepID: input.stepID, laneID: lane.ID, moduleKey: lane.Merge.Module, source: doc, sourceID: doc.ID, sourceInput: sourceInput.Clone(), sessionID: sessionID, references: mergeReferences, metadata: input.Metadata, value: result.Value, candidate: &serializedCandidate}, prepared.mergeValidators, attempt, input.Debug)
|
||||
attemptWarnings = append(attemptWarnings, warnings...)
|
||||
payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected)}
|
||||
if validateErr != nil || rejected != nil {
|
||||
return false, rejected, terminal.record(payload, validateErr)
|
||||
if validateErr != nil {
|
||||
return retryAttemptResult{}, terminal.record(payload, validateErr)
|
||||
}
|
||||
if rejected != nil {
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
return retryAttemptResult{rejection: rejected, warnings: attemptWarnings}, nil
|
||||
}
|
||||
stored, encodeErr := checkpointArtifact(typed.codec, candidate.LaneID, candidate.MergerKey, candidate.SourceID, candidate.Value)
|
||||
if encodeErr != nil {
|
||||
attemptErr := fmt.Errorf("serialize accepted merge output for lane %q: %w", lane.ID, encodeErr)
|
||||
return false, nil, terminal.record(payload, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(payload, attemptErr)
|
||||
}
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return false, nil, debugErr
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
merged, serializedMerge = candidate, stored
|
||||
mergeWarnings = attemptWarnings
|
||||
return true, nil, nil
|
||||
return retryAttemptResult{accepted: true}, nil
|
||||
})
|
||||
if runErr != nil {
|
||||
_ = checkpointMergeFailed(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps, runErr)
|
||||
return stageResult, runErr
|
||||
}
|
||||
if !ok {
|
||||
output.Rejected = append(output.Rejected, *rejection)
|
||||
if err := checkpointMergeRejected(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps, *rejection); err != nil {
|
||||
if !retryResult.accepted {
|
||||
output.Warnings = append(output.Warnings, retryResult.warnings...)
|
||||
output.Rejected = append(output.Rejected, *retryResult.rejection)
|
||||
if err := checkpointMergeRejected(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps, *retryResult.rejection); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
stageResult.terminal = true
|
||||
@@ -303,7 +312,7 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints
|
||||
return stageResult, err
|
||||
}
|
||||
}
|
||||
if err := writeDebugTimed(input.Debug, path.Join("merge", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedMerge), "warnings": debugWarningEnvelopes(mergeWarnings)}}); err != nil {
|
||||
if err := writeDebugTimed(input.Debug, path.Join("merge", fileio.EncodePathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedMerge), "warnings": debugWarningEnvelopes(mergeWarnings)}}); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
stageResult.artifact = merged
|
||||
@@ -329,7 +338,7 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
|
||||
return stageResult, err
|
||||
}
|
||||
normalizeDecision = normalizeResolution.decision
|
||||
if err := writeDebugTimed(input.Debug, path.Join("normalize", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "decision": normalizeDecision, "source": debugSourceDocumentEnvelope(doc), "merge_output": debugCheckpointArtifact(serializedMerge), "options": redactSensitiveMap(lane.Normalize.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
|
||||
if err := writeDebugTimed(input.Debug, path.Join("normalize", fileio.EncodePathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "decision": normalizeDecision, "source": debugSourceDocumentEnvelope(doc), "merge_output": debugCheckpointArtifact(serializedMerge), "options": redactSensitiveMap(lane.Normalize.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
var serializedNormalize CheckpointArtifact
|
||||
@@ -342,30 +351,30 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
|
||||
if err := checkpointNormalizeRunning(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
ok, rejection, runErr := runWithRetry(ctx, lane.Normalize.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) {
|
||||
retryResult, runErr := runWithRetry(ctx, lane.Normalize.Retries, func(attempt int) (retryAttemptResult, error) {
|
||||
started := time.Now().UTC()
|
||||
attemptPath := path.Join("normalize", debugPathComponent(lane.ID), fmt.Sprintf("attempt-%02d", attempt))
|
||||
attemptPath := path.Join("normalize", fileio.EncodePathComponent(lane.ID), fmt.Sprintf("attempt-%02d", attempt))
|
||||
attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath)
|
||||
terminal := newAttemptTerminalRecorder(input.Debug, attemptPath, "normalize", llmScope, debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, Attempt: attempt, StartedAt: started})
|
||||
requestMetadata, metadataErr := cloneMetadata(input.Metadata)
|
||||
if metadataErr != nil {
|
||||
return false, nil, terminal.record(nil, fmt.Errorf("clone normalize request metadata: %w", metadataErr))
|
||||
return retryAttemptResult{}, terminal.record(nil, fmt.Errorf("clone normalize request metadata: %w", metadataErr))
|
||||
}
|
||||
result, callErr := typed.normalize(attemptCtx, typed.normalizer, contracts.TypedNormalizeRequest[any]{Source: doc, LaneID: lane.ID, MergeOutput: contracts.MergeArtifact[any]{LaneID: lane.ID, MergerKey: lane.Merge.Module, SourceID: doc.ID, Value: merged.Value}, SourceInput: sourceInput.Clone(), SessionID: sessionID, References: CloneReferenceSet(normalizeReferences), LLMProfile: lane.Normalize.LLMProfile, Metadata: requestMetadata})
|
||||
if callErr != nil {
|
||||
attemptErr := fmt.Errorf("normalize lane %q with normalizer %q: %w", lane.ID, lane.Normalize.Module, callErr)
|
||||
return false, nil, terminal.record(nil, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(nil, attemptErr)
|
||||
}
|
||||
attemptWarnings := cloneWarnings(result.Warnings)
|
||||
serializedCandidate, encodeErr := serializeCandidateArtifact(typed.codec, lane.ID, lane.Normalize.Module, doc.ID, result.Value)
|
||||
if encodeErr != nil {
|
||||
attemptErr := fmt.Errorf("serialize normalize candidate for lane %q: %w", lane.ID, encodeErr)
|
||||
return false, nil, terminal.record(map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}, attemptErr)
|
||||
}
|
||||
var retryPayload map[string]any
|
||||
if result.Retry != nil {
|
||||
if err := validateNormalizeRetry(result.Retry); err != nil {
|
||||
return false, nil, terminal.record(map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings)}, fmt.Errorf("normalize lane %q returned invalid retry directive: %w", lane.ID, err))
|
||||
return retryAttemptResult{}, terminal.record(map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings)}, fmt.Errorf("normalize lane %q returned invalid retry directive: %w", lane.ID, err))
|
||||
}
|
||||
retryRemaining := attempt <= lane.Normalize.Retries
|
||||
retryPayload = map[string]any{
|
||||
@@ -376,7 +385,7 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
|
||||
}
|
||||
if retryRemaining {
|
||||
payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "retry": retryPayload}
|
||||
return false, nil, terminal.record(payload, nil)
|
||||
return retryAttemptResult{}, terminal.record(payload, nil)
|
||||
}
|
||||
attemptWarnings = append(attemptWarnings, cloneWarnings(result.Retry.FallbackWarnings)...)
|
||||
}
|
||||
@@ -386,28 +395,35 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
|
||||
if retryPayload != nil {
|
||||
payload["retry"] = retryPayload
|
||||
}
|
||||
if validateErr != nil || rejected != nil {
|
||||
return false, rejected, terminal.record(payload, validateErr)
|
||||
if validateErr != nil {
|
||||
return retryAttemptResult{}, terminal.record(payload, validateErr)
|
||||
}
|
||||
if rejected != nil {
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
return retryAttemptResult{rejection: rejected, warnings: attemptWarnings}, nil
|
||||
}
|
||||
stored, encodeErr := checkpointArtifact(typed.codec, lane.ID, lane.Normalize.Module, doc.ID, result.Value)
|
||||
if encodeErr != nil {
|
||||
attemptErr := fmt.Errorf("serialize accepted normalize output for lane %q: %w", lane.ID, encodeErr)
|
||||
return false, nil, terminal.record(payload, attemptErr)
|
||||
return retryAttemptResult{}, terminal.record(payload, attemptErr)
|
||||
}
|
||||
if debugErr := terminal.record(payload, nil); debugErr != nil {
|
||||
return false, nil, debugErr
|
||||
return retryAttemptResult{}, debugErr
|
||||
}
|
||||
serializedNormalize = stored
|
||||
normalizeWarnings = attemptWarnings
|
||||
return true, nil, nil
|
||||
return retryAttemptResult{accepted: true}, nil
|
||||
})
|
||||
if runErr != nil {
|
||||
_ = checkpointNormalizeFailed(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps, runErr)
|
||||
return stageResult, runErr
|
||||
}
|
||||
if !ok {
|
||||
output.Rejected = append(output.Rejected, *rejection)
|
||||
if err := checkpointNormalizeRejected(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps, *rejection); err != nil {
|
||||
if !retryResult.accepted {
|
||||
output.Warnings = append(output.Warnings, retryResult.warnings...)
|
||||
output.Rejected = append(output.Rejected, *retryResult.rejection)
|
||||
if err := checkpointNormalizeRejected(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps, *retryResult.rejection); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
return stageResult, nil
|
||||
@@ -417,7 +433,7 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
|
||||
return stageResult, err
|
||||
}
|
||||
}
|
||||
if err := writeDebugTimed(input.Debug, path.Join("normalize", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedNormalize), "warnings": debugWarningEnvelopes(normalizeWarnings)}}); err != nil {
|
||||
if err := writeDebugTimed(input.Debug, path.Join("normalize", fileio.EncodePathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedNormalize), "warnings": debugWarningEnvelopes(normalizeWarnings)}}); err != nil {
|
||||
return stageResult, err
|
||||
}
|
||||
stageResult.serialized = serializedNormalize
|
||||
@@ -469,7 +485,7 @@ func (r *Runner) validateTypedArtifact(ctx context.Context, codec artifactCodecE
|
||||
var result contracts.ValidationResult
|
||||
var err error
|
||||
started := time.Now().UTC()
|
||||
attemptPath := path.Join("validate", debugPathComponent(string(target.stage)), debugPathComponent(target.laneID), debugPathComponent(target.moduleKey), fmt.Sprintf("%02d-%s-attempt-%02d", index+1, debugPathComponent(binding.Module), attempt))
|
||||
attemptPath := path.Join("validate", fileio.EncodePathComponent(string(target.stage)), fileio.EncodePathComponent(target.laneID), fileio.EncodePathComponent(target.moduleKey), fmt.Sprintf("%02d-%s-attempt-%02d", index+1, fileio.EncodePathComponent(binding.Module), attempt))
|
||||
validatorCtx, llmScope := withIsolatedDebugLLMScope(ctx, attemptPath)
|
||||
requestTarget := target
|
||||
requestTarget.sourceInput = target.sourceInput.Clone()
|
||||
@@ -488,6 +504,12 @@ func (r *Runner) validateTypedArtifact(ctx context.Context, codec artifactCodecE
|
||||
}
|
||||
switch item.resolved.Target {
|
||||
case ValidatorTargetTyped:
|
||||
candidateValue, decodeErr := decodeTypedValidationCandidate(codec, *target.candidate)
|
||||
if decodeErr != nil {
|
||||
err = fmt.Errorf("decode %s candidate for typed validator %q: %w", target.stage, binding.Module, decodeErr)
|
||||
break
|
||||
}
|
||||
requestTarget.value = candidateValue
|
||||
requestTarget.llmProfile = binding.LLMProfile
|
||||
result, err = item.typedValidate(validatorCtx, item.typed, requestTarget)
|
||||
case ValidatorTargetSerialized:
|
||||
@@ -547,3 +569,15 @@ func validationCandidateArtifact(codec artifactCodecEntry, target typedValidatio
|
||||
}
|
||||
return serializeCandidateArtifact(codec, target.laneID, target.moduleKey, target.sourceID, target.value)
|
||||
}
|
||||
|
||||
func decodeTypedValidationCandidate(codec artifactCodecEntry, candidate CheckpointArtifact) (any, error) {
|
||||
value, err := codec.decodeCandidate(candidate.Artifact.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actualType := reflect.TypeOf(value)
|
||||
if actualType != codec.valueType {
|
||||
return nil, newArtifactCodecTypeError("decode candidate", codec.spec.Kind, codec.valueType, actualType)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user