Compare commits
298 Commits
v0.1.0
...
9614469b45
| Author | SHA1 | Date | |
|---|---|---|---|
| 9614469b45 | |||
| 29ee68824d | |||
| aeaaf44ae0 | |||
| d752c51aec | |||
| 8199d95dc1 | |||
| 97cdb01357 | |||
| 7a66095912 | |||
| 9d1356a20e | |||
| e4471fc300 | |||
| 84a2854b5e | |||
| a1b76093ce | |||
| 1aa30a73db | |||
| dc7c0e2f9e | |||
| e2cb0d901a | |||
| 8e0b029f5f | |||
| 9bbf2535dd | |||
| 83fde83a58 | |||
| bef3d1359d | |||
| 1ff449435f | |||
| 6e21c83fd8 | |||
| 6dc9d522b1 | |||
| 8adcf6840d | |||
| f5ed30e455 | |||
| cacf3f24e7 | |||
| f08ca4ddfa | |||
| e1c2f3c202 | |||
| 9614eb540d | |||
| 1b46596a39 | |||
| e043d61a99 | |||
| ad89782c9b | |||
| cd29265d5d | |||
| 2f36b7c3b6 | |||
| b8b3f3abfa | |||
| b490297cde | |||
| 06148074a2 | |||
| 16a998055c | |||
| 97c9a8e5ce | |||
| 66415fd1fa | |||
| bfe25609a7 | |||
| 36e0512454 | |||
| b02f667107 | |||
| ed2b6f4580 | |||
| cb7f145c76 | |||
| 2b9d2eaeaa | |||
| 61016671ab | |||
| b2c076946b | |||
| 250c5c22b8 | |||
| 4b0b166143 | |||
| 90481a0e4b | |||
| 2cbaf20e55 | |||
| a263a0840c | |||
| 14991cf58b | |||
| ab0b4e350c | |||
| 7b2fb0880d | |||
| 748e02db80 | |||
| 23c55f8925 | |||
| 906d97b391 | |||
| f15fd4f9c1 | |||
| 7aadb088a6 | |||
| 9de399432e | |||
| 5bdd56cfb1 | |||
| 64ea23c21f | |||
| 7071102ab7 | |||
| 9184072839 | |||
| c437682407 | |||
| 22d4f29670 | |||
| afb7ed3cf1 | |||
| f846f252c0 | |||
| f5618d1f0c | |||
| f94ab0a6bf | |||
| 41b52aae74 | |||
| ed36f7d7fd | |||
| 3ba2bfd7f6 | |||
| b344d16dc1 | |||
| 6e12c09952 | |||
| 07460341e3 | |||
| 732b13669f | |||
| 3a8a82ebc9 | |||
| 1c9819f08e | |||
| 447c4f73f9 | |||
| e01b8d1b6d | |||
| 3d70920f3d | |||
| 110593ece1 | |||
| a1f5dce405 | |||
| 50aa60e0b8 | |||
| 2fbb3813aa | |||
| 6dd695611c | |||
| 92acb45775 | |||
| d3e171aa82 | |||
| c6f330eb06 | |||
| 20cfbfd311 | |||
| fb043325e1 | |||
| 06c0259788 | |||
| 3d5fd9dc05 | |||
| 3ba2c62cc1 | |||
| e2ab01f9d2 | |||
| 5186e061a8 | |||
| c4c907d421 | |||
| fa5076f5f1 | |||
| 8b5a4e0efd | |||
| 3eb68baca6 | |||
| ae97adb8b0 | |||
| 79b9fffcaf | |||
| be22852daa | |||
| f5107045c3 | |||
| 2c98763b9b | |||
| d2eb763b9b | |||
| 0f25e7339f | |||
| 87c57681f6 | |||
| 3d0d79360e | |||
| f08b407b72 | |||
| 4ff2c7795f | |||
| 3bfe05ab56 | |||
| 7806dba509 | |||
| ac53f83ac8 | |||
| 385e4593f4 | |||
| f64bb7c883 | |||
| 9d3175d36a | |||
| 4f96abf42c | |||
| d88bcb6070 | |||
| 0cca3b1f5d | |||
| bbc83ab042 | |||
| 2cba6d4512 | |||
| e70450c401 | |||
| 4d3351c774 | |||
| a586257d5e | |||
| b8163091cc | |||
| c7b3af82b4 | |||
| a42b06ba20 | |||
| 8d62973627 | |||
| 8cdefc72a1 | |||
| d3a8dc7930 | |||
| 86ebb62f84 | |||
| 50191ee694 | |||
| 3c35124db4 | |||
| e4ec521bed | |||
| 2111e01142 | |||
| a39eea7ed6 | |||
| 7bcce9953e | |||
| 9746a42e04 | |||
| 47bacc7abb | |||
| 8824948910 | |||
| 8cb11e60e4 | |||
| 26142f0e05 | |||
| 1542a12497 | |||
| a9250206d5 | |||
| 5bd0ba7a72 | |||
| 286fb9dce7 | |||
| 9fa9154dda | |||
| 604c7a7945 | |||
| a0f5e6e2b9 | |||
| 561d65a505 | |||
| 205e2a9908 | |||
| 8c59b6af14 | |||
| b3328b93e5 | |||
| 96a49bb7cd | |||
| 6d0a19c94c | |||
| 6fc6ce0adb | |||
| 8ba5228c01 | |||
| 51a36efb6b | |||
| ebd449d847 | |||
| 7844c0a93f | |||
| 3bfac14397 | |||
| 1c13e1d64a | |||
| 60b86dc40c | |||
| 68481804a7 | |||
| 236ccc62ad | |||
| 35bffdf336 | |||
| 3772b308e9 | |||
| ef6926322d | |||
| 2df7084d5d | |||
| 3d3cc0c08e | |||
| fbc3d9add6 | |||
| 35e45f0914 | |||
| 3e4fa923eb | |||
| 3013ee044d | |||
| adfd3bd052 | |||
| 4023c66508 | |||
| adfe3825ee | |||
| 814fcdc6ba | |||
| 66de1a5520 | |||
| 52e6b31408 | |||
| 142ba36695 | |||
| b949e9bbc0 | |||
| ce3a07512f | |||
| 1c84d19e5f | |||
| fc1b57bde2 | |||
| 075888c97f | |||
| 40709e4ad8 | |||
| 15c369c509 | |||
| a81b9f1e1f | |||
| 0327659355 | |||
| c99bad19ae | |||
| 35f9446ed8 | |||
| 21888d625f | |||
| feb03c3f8e | |||
| 3b07b64a0f | |||
| 6e6375521d | |||
| b1fe9dc5a7 | |||
| 6db2dc8d2a | |||
| 98b03a4629 | |||
| 610bdb4fea | |||
| 68ec69f2e4 | |||
| 451f6c0bb9 | |||
| 3011dd91ca | |||
| ae65b95374 | |||
| a5bbfea9b9 | |||
| ae9c2e1d5e | |||
| 1d3a444df8 | |||
| f044c00a7c | |||
| 7d89c2702b | |||
| 93653cccb8 | |||
| a024492dbf | |||
| 304c68f9fc | |||
| c5f2b14ff4 | |||
| fc8e03f98c | |||
| 16de4b6437 | |||
| 0f30888b00 | |||
| 3e67be6ac3 | |||
| 5ef027b6f0 | |||
| 666b4bf801 | |||
| d593bfee0a | |||
| b7ad66f0e0 | |||
| 249e49c928 | |||
| e54e74ed88 | |||
| 582c5dceed | |||
| a9d8505cdb | |||
| 7c95791e94 | |||
| aa14faa3cb | |||
| cc6b050367 | |||
| bcedf19a08 | |||
| c05ecb58d8 | |||
| 9e3f8809b3 | |||
| 4f057b99ac | |||
| aec807fcb0 | |||
| 79a585d17e | |||
| 671ff6d132 | |||
| 9b2d0297b7 | |||
| f91e643932 | |||
| 35fe405448 | |||
| 524f2ffb8e | |||
| 68b426cdb0 | |||
| 223f3751e8 | |||
| 7861d040df | |||
| 47cf7e76ec | |||
| 8cafa64174 | |||
| ecba0ad725 | |||
| b3757dcf7b | |||
| 3e456ec4d4 | |||
| 5b1efc89f6 | |||
| aee48d011e | |||
| 3217bb3e12 | |||
| 3df686f474 | |||
| 7d4c027d09 | |||
| 31d70a2dd7 | |||
| c9fbb331e2 | |||
| f6224dcbee | |||
| de6689bc1d | |||
| 0fc740470f | |||
| 49d94cc2e9 | |||
| 291298cf7b | |||
| 9532ae8121 | |||
| 7601731a2c | |||
| 3aa88ab9d3 | |||
| c1ba94192d | |||
| 22032dfd6d | |||
| 4cafde2502 | |||
| 8c623b7ad8 | |||
| 43dc954440 | |||
| 51053d390d | |||
| 9278797aa9 | |||
| 39e49d7f77 | |||
| 84c4c06712 | |||
| eab640aa21 | |||
| a516944086 | |||
| be6803ffa1 | |||
| ef4bdd4f9f | |||
| 2f97895732 | |||
| 9e89b88efc | |||
| a57c6397e3 | |||
| 39e071f5ca | |||
| 70d733edaf | |||
| 1c31f56af1 | |||
| f9999a73df | |||
| 11d8187052 | |||
| 86bff552c1 | |||
| d3f790095e | |||
| 95218218e2 | |||
| e700df82d8 | |||
| e19cc02c4d | |||
| 8a5419448f | |||
| c8217549a8 | |||
| 2130414899 | |||
| 7f83a20fa6 | |||
| 317ab0472d | |||
| e5eb0ba5c8 | |||
| b95af4f87d | |||
| 11073b613c |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,3 +1,8 @@
|
||||
# build and testing artifacts
|
||||
notarius
|
||||
notarius-output
|
||||
workspace/
|
||||
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
@@ -49,6 +54,7 @@ go.work.sum
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
Please carefully review the documents in `docs/policy` before making any changes to this repository.
|
||||
- `architecture.md` provides the canonical high-level architecture policy for this repository.
|
||||
- `documentation.md` provides the canonical documentation policy for this repository.
|
||||
Please review `docs/development.md` for initial orientation in this repository
|
||||
and follow its task-specific reading guide.
|
||||
|
||||
26
README.md
26
README.md
@@ -4,33 +4,31 @@ Notarius is a Go CLI for extracting structured artifacts from source material
|
||||
with explicit, configurable pipeline modules.
|
||||
|
||||
The current implementation reads Seriatim transcript JSON, chunks the source
|
||||
units, extracts D&D spell-cast artifacts with an OpenAI-compatible LLM, and
|
||||
writes JSON output plus diagnostics for each run.
|
||||
units, extracts D&D spell-cast artifacts with a Scriptorium-backed LLM runtime,
|
||||
and writes JSON output. Add `--debug` when a per-run inspection bundle is
|
||||
needed.
|
||||
|
||||
```sh
|
||||
NOTARIUS_LLM_DEFAULT_BASE_URL=http://127.0.0.1:8080/v1 \
|
||||
NOTARIUS_LLM_DEFAULT_MODEL=your-model \
|
||||
OPENROUTER_API_KEY=... \
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config examples/dnd-spells.config.yml \
|
||||
--config examples/dnd-minimal.config.yml \
|
||||
--input examples/seriatim-minimal-transcript.json
|
||||
```
|
||||
|
||||
If the provider requires authentication, set
|
||||
`NOTARIUS_LLM_DEFAULT_API_KEY` in the environment before running the command.
|
||||
Outputs are written under `./notarius-output/<run-id>/` unless `--output-dir`
|
||||
is provided.
|
||||
This invocation uses the maintained example configuration and input. See the
|
||||
configuration and operations references for profile selection, credentials, and
|
||||
run artifacts.
|
||||
|
||||
Useful references:
|
||||
|
||||
- [CLI reference](docs/cli.md)
|
||||
- [Configuration reference](docs/config.md)
|
||||
- [Operations](docs/operations.md)
|
||||
- [Troubleshooting](docs/troubleshooting.md)
|
||||
- [Seriatim input contract](docs/integrations/seriatim.md)
|
||||
- [OpenAI-compatible provider contract](docs/integrations/openai-compatible.md)
|
||||
- [JSON output contract](docs/integrations/json-output.md)
|
||||
- [D&D spell artifact contract](docs/integrations/dnd-spell-artifacts.md)
|
||||
- [Developer workflow](docs/policy/development.md)
|
||||
- [Internal architecture docs](docs/internal/overview.md)
|
||||
- [Maintained example config](examples/dnd-spells.config.yml)
|
||||
- [Developer guide](docs/development.md)
|
||||
- [Internal implementation docs](docs/internal/overview.md)
|
||||
- [Minimal D&D configuration](examples/dnd-minimal.config.yml)
|
||||
- [Complete D&D configuration](examples/dnd-complete.config.yml)
|
||||
- [Maintained example input](examples/seriatim-minimal-transcript.json)
|
||||
|
||||
23
docs/adr/0001-record-architecture-decisions.md
Normal file
23
docs/adr/0001-record-architecture-decisions.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# ADR-0001: Record architecture decisions as ADRs
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-07-13
|
||||
|
||||
## Context
|
||||
Architectural reasoning made during design (pattern choices, rejected
|
||||
alternatives, trigger conditions for revisiting) is lost if only the final
|
||||
state is documented.
|
||||
|
||||
## Decision
|
||||
We keep a living overview in docs/policy/architecture.md describing current
|
||||
intended state, and immutable, numbered ADRs (Nygard format) in docs/adr/
|
||||
recording each significant decision, its alternatives, and its consequences.
|
||||
Changed decisions get a new ADR that marks the old one Superseded.
|
||||
|
||||
## Alternatives considered
|
||||
- Overview doc only: loses the "why" and the rejected options.
|
||||
- arc42 / RFC-style design docs: heavier than warranted for a solo repo.
|
||||
|
||||
## Consequences
|
||||
Small ongoing writing cost; durable reasoning trail; cheap onboarding for
|
||||
future contributors (including future-us).
|
||||
51
docs/adr/0002-linear-pipes-and-filters-pipeline.md
Normal file
51
docs/adr/0002-linear-pipes-and-filters-pipeline.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# ADR-0002: Linear pipes-and-filters pipeline, not a general DAG
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-07-13
|
||||
|
||||
## Context
|
||||
|
||||
Notarius processes source material through one known workflow:
|
||||
|
||||
```text
|
||||
input -> chunk -> extract -> merge -> normalize -> output
|
||||
```
|
||||
|
||||
Input and chunking apply to the source as a whole. Each selected artifact lane
|
||||
then performs extract, merge, and normalize, after which output aggregates the
|
||||
lane outcomes. Chunk extraction has a natural scatter-gather shape, but no
|
||||
current use case requires arbitrary branches, joins, or user-defined stage
|
||||
topology.
|
||||
|
||||
## Decision
|
||||
|
||||
Notarius implements a fixed six-stage pipes-and-filters pipeline. Configuration
|
||||
selects implementations for these stages but cannot add stages, reorder them,
|
||||
or define an arbitrary graph.
|
||||
|
||||
The framework owns stage sequencing and the scatter-gather boundary between
|
||||
chunk, extract, and merge. Extract results are handed to merge in deterministic
|
||||
source-chunk order regardless of execution strategy. Each artifact lane remains
|
||||
logically linear. Output runs after every selected lane has either produced an
|
||||
accepted normalized artifact or reached a recorded rejection. A framework
|
||||
execution failure aborts the pipeline.
|
||||
|
||||
The runner's concrete internal representation and stage-specific scheduling
|
||||
policies are implementation details. Concurrency must preserve the pipeline's
|
||||
deterministic handoffs, validation behavior, and provenance, and all execution
|
||||
strategies must continue to honor context cancellation.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Build a general DAG engine now. This would support hypothetical branching
|
||||
topologies, but would add scheduling, topology validation, configuration, and
|
||||
state-management complexity without a current consumer. Revisit this choice
|
||||
only when a concrete workflow requires a topology the fixed pipeline cannot
|
||||
express.
|
||||
|
||||
## Consequences
|
||||
|
||||
The runner, configuration model, and operator mental model remain small. Stage
|
||||
ownership stays visible, and general chunking, merging, or normalization cannot
|
||||
be hidden inside extractors. A future DAG requirement will require an explicit
|
||||
architectural change rather than incremental exceptions to the fixed pipeline.
|
||||
119
docs/adr/0003-typed-interfaces-with-two-zone-data-model.md
Normal file
119
docs/adr/0003-typed-interfaces-with-two-zone-data-model.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# ADR-0003: Strongly typed stage interfaces with a two-zone data model
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-07-13
|
||||
|
||||
## Context
|
||||
|
||||
Pipeline stages must exchange source data and extracted artifacts. Universal
|
||||
source data has one engine-wide meaning, while extracted artifacts have
|
||||
domain-specific shapes. Passing opaque bytes or `any` between all stages would
|
||||
make invalid wiring and merge behavior runtime concerns. Requiring JSON at
|
||||
every handoff would preserve interoperability but discard useful Go type safety
|
||||
while all modules are in-process.
|
||||
|
||||
The framework must also support multiple configured artifact domains, durable
|
||||
checkpoints, diagnostics, and output encoders without making those consumers
|
||||
depend on every domain's Go types.
|
||||
|
||||
## Decision
|
||||
|
||||
Notarius uses two typed data zones followed by one serialized boundary.
|
||||
|
||||
### Source zone
|
||||
|
||||
Input and chunk stages use conservative, engine-owned document, segment, chunk,
|
||||
and source-reference types. Their exact Go names are implementation details.
|
||||
Every segment carries engine-owned source provenance identifying the source
|
||||
location from which it was produced. Chunks preserve the ordered provenance of
|
||||
their segments.
|
||||
|
||||
Source-format-specific fields remain in input modules or explicitly namespaced
|
||||
metadata; they do not become framework contracts.
|
||||
|
||||
### Domain artifact zone
|
||||
|
||||
Each artifact lane has one domain-owned Go artifact type `T`. Its extract,
|
||||
merge, normalize, and domain-aware validation implementations use generic,
|
||||
strongly typed contracts over the same `T`. Raw JSON, opaque bytes, and `any`
|
||||
are not stage-handoff contracts within a lane.
|
||||
|
||||
Each registered domain artifact type supplies a codec for `T`. The codec owns:
|
||||
|
||||
- stable schema identity and an explicit schema version;
|
||||
- JSON serialization and deserialization;
|
||||
- the media type and schema metadata required at serialized boundaries; and
|
||||
- rejection of data that cannot be represented by the declared artifact
|
||||
schema.
|
||||
|
||||
An artifact type's JSON representation is a maintained domain contract.
|
||||
Changing it incompatibly requires a new schema version.
|
||||
|
||||
Extract, merge, and normalize may change the contents of `T`, but they do not
|
||||
change the lane's canonical Go artifact type or artifact schema identity. An
|
||||
extractor maps any provider- or prompt-specific response type into `T` before
|
||||
returning. A future lane that requires different artifact types at different
|
||||
stages requires a new architectural decision.
|
||||
|
||||
### Serialized boundary
|
||||
|
||||
After normalization, each typed artifact is converted into an engine-owned
|
||||
serialized artifact containing bytes, media type, and schema metadata. Output
|
||||
aggregation and output encoders consume this type-erased form. Intermediate
|
||||
checkpoint and debug encodings do not become stage-handoff contracts.
|
||||
|
||||
LLM transport, checkpoints, and opt-in debug recording are also explicit
|
||||
serialization boundaries. They may encode or decode a typed artifact through
|
||||
its domain codec, but they do not change the in-memory type used between
|
||||
extract, merge, normalize, and typed validators. Checkpoint reuse requires a
|
||||
compatible schema identity and version.
|
||||
|
||||
An LLM structured-response schema is a module transport contract and may differ
|
||||
from the domain artifact schema. The calling module owns the response type and
|
||||
maps it into the canonical `T`; the artifact codec remains authoritative for
|
||||
artifact checkpoints and output serialization.
|
||||
|
||||
The framework may use private type-erased adapters to store heterogeneous lane
|
||||
registrations and execute configured domains. Such an adapter must assemble a
|
||||
type-consistent lane before execution and must not expose `any` or raw payloads
|
||||
as module-facing handoffs inside the domain artifact zone.
|
||||
|
||||
### Construction and dependencies
|
||||
|
||||
Every module operation accepts `context.Context`. Modules receive stable runtime
|
||||
collaborators through an injected dependency set at construction time. In
|
||||
particular, LLM-using modules receive the application-provided structured LLM
|
||||
client and do not construct provider clients or bypass shared scheduling.
|
||||
|
||||
The application boundary enforces one configurable global upper bound on
|
||||
in-flight LLM calls across all stages, lanes, retries, and validators.
|
||||
|
||||
Configuration options are parsed and validated while a module is constructed,
|
||||
before that module executes. Per-run data such as source material, references,
|
||||
session identity, and lane identity remains operation input rather than a
|
||||
construction dependency.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Pass raw bytes between stages. This maximizes decoupling but moves wiring,
|
||||
parsing, and merge errors to runtime and prevents domain types from being the
|
||||
canonical in-process contract.
|
||||
- Require JSON plus schemas at every stage boundary. This is appropriate for an
|
||||
out-of-process boundary, but adds serialization and parsing inside the current
|
||||
in-process pipeline. The stable codec contract preserves this upgrade path if
|
||||
remote plugins are introduced.
|
||||
- Use a uniform `Process(any) (any, error)` contract. This simplifies a fully
|
||||
dynamic engine but turns incompatible module composition into type assertions
|
||||
and runtime failures. The fixed topology does not require that tradeoff.
|
||||
|
||||
## Consequences
|
||||
|
||||
Domain pipelines gain compile-time handoff safety and explicit merge semantics.
|
||||
Serialization, schema compatibility, checkpoint decoding, and output erasure
|
||||
have named owners. Dynamic registration requires a small erased adapter around
|
||||
each typed lane, and generic stage implementations must be instantiated for a
|
||||
specific artifact type or behavior rather than manipulating arbitrary JSON.
|
||||
|
||||
The engine-owned source model becomes a long-lived contract and must evolve
|
||||
conservatively. Domain authors must maintain a codec and versioned schema in
|
||||
addition to their Go artifact type.
|
||||
81
docs/adr/0004-package-modules-by-domain.md
Normal file
81
docs/adr/0004-package-modules-by-domain.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# ADR-0004: Package modules by domain, not by stage
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-07-13
|
||||
|
||||
## Context
|
||||
|
||||
Module packages can be grouped first by pipeline stage, such as
|
||||
`modules/chunk/dnd/scenes`, or first by domain, such as
|
||||
`modules/dnd/chunk/scenes`. A domain's extract, merge, normalize, validation,
|
||||
schema, prompt, and artifact-codec implementations collaborate around the same
|
||||
artifact types and are likely to evolve together.
|
||||
|
||||
Go package dependencies also constrain registration. If shared types live in a
|
||||
domain root package, that package cannot import child implementation packages
|
||||
to register them because the children already import the root types.
|
||||
|
||||
## Decision
|
||||
|
||||
Production extensions are grouped by domain under:
|
||||
|
||||
```text
|
||||
internal/modules/<domain>/<stage>/<name>
|
||||
```
|
||||
|
||||
Shared artifact types live at the domain root, for example
|
||||
`internal/modules/dnd/types.go`. Domain-specific validators, prompt fragments,
|
||||
schemas, reference helpers, and codecs also live within that domain tree.
|
||||
|
||||
Each domain exposes one production registration entry point from a sibling
|
||||
registrar package, for example `internal/modules/dnd/register`. The registrar
|
||||
may import the domain root and its child implementations; the domain root does
|
||||
not import its registrar or child packages. This keeps shared types available
|
||||
as `dnd.SpellList` without creating a Go import cycle.
|
||||
|
||||
The `generic` tree is a peer extension family for reusable implementations that
|
||||
contain no concrete source-format or artifact-domain knowledge. Source-format
|
||||
and output-format families, such as Seriatim and JSON output, follow the same
|
||||
domain-first organization even when they do not define a type in the
|
||||
[domain artifact zone](0003-typed-interfaces-with-two-zone-data-model.md#domain-artifact-zone).
|
||||
|
||||
Concrete domain implementation packages do not import another concrete domain.
|
||||
Generic extension packages never import concrete domains. A domain registrar
|
||||
may import domain-neutral generic extension packages to instantiate a reusable
|
||||
strategy for that domain's artifact type; the generic implementation remains
|
||||
unaware of the concrete type's domain semantics. Reuse needed directly by a
|
||||
domain implementation lives in a domain-neutral framework or helper package,
|
||||
not in a peer extension package.
|
||||
|
||||
The application composition root may import multiple registrar packages, and
|
||||
black-box integration tests may compose multiple domains. Other cross-domain
|
||||
reuse occurs through engine contracts and composition-time registration rather
|
||||
than concrete peer-domain imports.
|
||||
|
||||
A domain registrar owns registration of that domain's modules, validators,
|
||||
default validator chains, artifact codecs, schemas, and prompt assets. It does
|
||||
not take ownership of application execution or process behavior.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Group modules by stage. This keeps interchangeable strategies side by side,
|
||||
but scatters a domain's shared artifact model and collaborating extensions
|
||||
across the repository. It is preferable when generic strategy libraries
|
||||
dominate or when the project is primarily a stage-extension framework rather
|
||||
than an application composed from domain suites.
|
||||
- Put both shared types and `Register` in the domain root. This gives the
|
||||
shortest import path but creates an import cycle once child implementations
|
||||
import the root artifact types.
|
||||
|
||||
## Consequences
|
||||
|
||||
The repository layout makes supported domains immediately visible, and adding
|
||||
or extracting a domain affects one cohesive subtree. The CLI composition root
|
||||
depends on a small set of domain registrars instead of every leaf package.
|
||||
|
||||
Package moves must preserve user-visible module and validator keys unless a
|
||||
separate compatibility decision changes them. Shared behavior that cannot be
|
||||
expressed through framework contracts may need to move into a domain-neutral
|
||||
framework package rather than creating a concrete peer-domain import. Registrar
|
||||
packages become explicit composition points for instantiating generic typed
|
||||
strategies, in addition to registering domain-owned implementations.
|
||||
141
docs/adr/0005-cache-canonical-chunk-plans-by-source.md
Normal file
141
docs/adr/0005-cache-canonical-chunk-plans-by-source.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# ADR-0005: Cache one canonical chunk plan per source
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-07-17
|
||||
|
||||
## Context
|
||||
|
||||
Notarius may run several extraction passes over the same source. A D&D
|
||||
transcript, for example, may first produce NPC artifacts and later produce
|
||||
spell or combat artifacts, with output from an earlier pass supplied as a
|
||||
reference to a later pass.
|
||||
|
||||
An LLM-backed chunker may process an entire, potentially large source in one
|
||||
expensive request. Recomputing boundaries for every pipeline or pass repeats
|
||||
that cost and can make otherwise comparable extraction runs use different
|
||||
source partitions. Stable chunk material also gives later extraction requests
|
||||
a better opportunity to benefit from provider-side prompt caching.
|
||||
|
||||
Chunk boundaries can affect extraction quality. Evidence may span a boundary,
|
||||
overlap may produce duplicates, and different partitions may change the context
|
||||
available to a model. Merge and normalization should remove structural signs
|
||||
of chunking from durable output, but they cannot guarantee recovery of evidence
|
||||
that an extractor did not receive.
|
||||
|
||||
Notarius therefore needs an explicit policy for choosing between automatically
|
||||
applying the latest chunking configuration and preserving one stable partition
|
||||
for repeated work on the same source.
|
||||
|
||||
## Decision
|
||||
|
||||
Notarius assigns one active canonical chunk plan to a source and reuses that
|
||||
plan by default across pipelines and invocations.
|
||||
|
||||
The canonical source identity is derived from the validated generic source
|
||||
document and covers the source-unit identity, order, and content needed to
|
||||
interpret plan boundaries. Input-adapter and chunk-producer identities are
|
||||
recorded as provenance, but the active-plan lookup does not vary with:
|
||||
|
||||
- pipeline identity or selected artifact lanes;
|
||||
- the configured chunk module or its options;
|
||||
- references;
|
||||
- LLM provider, model, profile, prompt, or response schema; or
|
||||
- configuration for later pipeline stages.
|
||||
|
||||
When an active plan exists, Notarius uses it even if the current pipeline
|
||||
configures a different chunk module or different chunk-module settings. The
|
||||
configured chunk module generates a plan only when none exists or when the
|
||||
operator explicitly requests recomputation.
|
||||
|
||||
The framework-owned minimum plan contract is an ordered, non-empty set of
|
||||
source-unit ranges. Each range identifies the inclusive start and end unit for
|
||||
one chunk. A chunk module may also provide namespaced, domain-specific
|
||||
annotations at plan or range scope. Those annotations are stored with the plan
|
||||
and passed through the pipeline when present, but they remain optional.
|
||||
Downstream stages must not assume that annotations associated with the
|
||||
currently configured chunk module are present on a reused plan produced by a
|
||||
different module.
|
||||
|
||||
The cache stores the plan rather than fully materialized chunks. The framework
|
||||
validates a reused plan against the current source and deterministically
|
||||
materializes its ranges into chunks. The same source and plan must produce
|
||||
byte-stable chunk input for later stages.
|
||||
|
||||
Canonical plan storage is a distinct cache surface with an independently
|
||||
configurable location. It is not coupled to the roots or lifecycles of
|
||||
invocation checkpoints, diagnostics, debug artifacts, or durable output. This
|
||||
allows per-user and system-service deployments to apply cache-specific
|
||||
ownership, permissions, placement, and cleanup policy without relocating other
|
||||
Notarius state.
|
||||
|
||||
One mutable active plan is stored under the canonical source identity and
|
||||
retains provenance for the module and relevant runtime inputs that produced it.
|
||||
Refreshing the active plan atomically replaces that one mutable record; readers
|
||||
must observe either the previous complete plan or the replacement complete
|
||||
plan, never a partial update.
|
||||
The effective plan producer is reported separately from the chunk module
|
||||
requested by the current pipeline; reuse must not attribute cached boundaries
|
||||
or annotations to a module that did not produce them.
|
||||
|
||||
Reuse is enabled by default. Operators can explicitly:
|
||||
|
||||
- bypass cached plans for an invocation without changing the active plan; or
|
||||
- recompute a plan with the configured chunk module and make it active for
|
||||
later work.
|
||||
|
||||
Exact storage layout, configuration fields, CLI syntax, publication mechanics,
|
||||
recovery behavior, and diagnostics are implementation and operational
|
||||
contracts rather than part of this decision.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Recompute chunks on every invocation. This always applies the current
|
||||
chunking configuration, but repeats the most expensive stage and weakens
|
||||
provider-side caching and cross-pass comparability.
|
||||
- Cache every distinct chunking request by including module options,
|
||||
references, prompts, profiles, and other runtime inputs in its identity. This
|
||||
closely associates a cached result with its producing request, but reduces
|
||||
reuse and permits boundary drift across operationally different passes.
|
||||
- Key plans by source plus chunk module and options. This shares plans across
|
||||
pipelines using the same strategy, but changing the configured strategy
|
||||
silently selects a different partition rather than preserving one canonical
|
||||
partition for the source.
|
||||
- Require operators to name or supply a plan for every run. Explicit selection
|
||||
is reproducible and may be useful as an advanced operation, but adds friction
|
||||
to the default workflow and does not provide automatic reuse.
|
||||
- Store fully materialized chunks. This simplifies loading, but duplicates
|
||||
source content and couples durable state to the current chunk representation
|
||||
rather than the stable boundary decision.
|
||||
- Store canonical plans beneath the general workspace root. This would reuse an
|
||||
existing location setting, but it couples a reusable application cache to
|
||||
checkpoint, diagnostic, and debug state that have different ownership,
|
||||
sensitivity, retention, and deployment requirements.
|
||||
|
||||
## Consequences
|
||||
|
||||
Independent pipelines and passes over the same source use stable boundaries by
|
||||
default. This reduces repeated LLM work, improves cross-pass comparability, and
|
||||
increases the opportunity for cached provider reads.
|
||||
|
||||
The configured chunk module may not execute, and its settings may have no
|
||||
effect, when an active plan already exists. Domain-specific annotations reflect
|
||||
the plan's original producer and may be absent or differ from those the current
|
||||
module would produce. User-visible provenance must make the effective plan
|
||||
clear.
|
||||
|
||||
A poor or outdated partition remains active until an operator replaces it.
|
||||
This can preserve suboptimal context boundaries and affect extraction recall or
|
||||
duplication even when merge and normalization hide the partition structure in
|
||||
durable output. Stable reuse is an intentional priority over automatically
|
||||
incorporating later chunk-strategy changes.
|
||||
|
||||
The framework gains a durable minimal chunk-plan contract and deterministic
|
||||
materialization responsibility. Chunk modules must separate required boundary
|
||||
output from optional annotations, and downstream modules may rely only on the
|
||||
minimal boundary contract unless a future decision introduces an explicit plan
|
||||
compatibility mechanism.
|
||||
|
||||
Operators must configure and secure canonical plan storage independently from
|
||||
other workspace state when the per-user default is not appropriate. Removing
|
||||
that cache remains recoverable because Notarius can regenerate it from the
|
||||
source, but doing so may repeat an expensive LLM operation.
|
||||
114
docs/adr/0006-separate-output-cache-and-debug-state.md
Normal file
114
docs/adr/0006-separate-output-cache-and-debug-state.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# ADR-0006: Separate output, cache, and debug state
|
||||
|
||||
**Status:** Superseded by [ADR-0007](0007-separate-checkpoint-recording-from-reuse.md)
|
||||
**Date:** 2026-07-17
|
||||
|
||||
## Context
|
||||
|
||||
Notarius currently exposes a workspace as a shared parent for checkpoints,
|
||||
debug artifacts, and preferred diagnostics settings. Diagnostics are a second
|
||||
inspection surface with their own enablement, directory, retention, and legacy
|
||||
configuration. Durable output uses a separate CLI-selected root, while the
|
||||
canonical chunk-plan cache introduced by ADR-0005 correctly uses an independent
|
||||
cache root.
|
||||
|
||||
These concepts reflect implementation history more than operator intent. A user
|
||||
must understand differences among workspace state, diagnostics, debug artifacts,
|
||||
checkpoints, and chunk plans before deciding where Notarius may write. Some of
|
||||
those distinctions are important internally: a redacted run summary has a
|
||||
different sensitivity from a trace containing source material, prompts, and
|
||||
model responses. They do not require separate public filesystem categories.
|
||||
|
||||
Notarius needs a smaller state model that communicates why data exists, how it
|
||||
may be treated, and whether it is reconstructible.
|
||||
|
||||
## Decision
|
||||
|
||||
Notarius exposes three filesystem surfaces: output, cache, and debug. The
|
||||
public workspace concept and diagnostics as a separate output surface are
|
||||
removed.
|
||||
|
||||
### Output
|
||||
|
||||
Output is the durable result of a run and the only surface intended for normal
|
||||
consumption. It contains the logical files produced by the output stage,
|
||||
including the maintained result, manifest, warning, and rejection contracts.
|
||||
Output is not cache or inspection state.
|
||||
|
||||
### Cache
|
||||
|
||||
Cache contains reconstructible state used to avoid repeated work or resume an
|
||||
interrupted workflow. Canonical chunk plans and invocation checkpoints are
|
||||
distinct cache families with independent identities, compatibility rules,
|
||||
enablement policies, locations, and cleanup lifecycles.
|
||||
|
||||
ADR-0005 continues to govern canonical chunk-plan selection and reuse. Grouping
|
||||
chunk plans and checkpoints under the public cache category does not permit a
|
||||
checkpoint to compete with canonical plan reuse or couple their storage roots.
|
||||
|
||||
Checkpointing is an invocation policy rather than a prerequisite hidden in
|
||||
persistent workspace configuration. An explicit resume invocation may read
|
||||
compatible checkpoints and record replacement checkpoint state for work it
|
||||
executes. Runs that do not request resume perform no checkpoint I/O.
|
||||
|
||||
### Debug
|
||||
|
||||
Debug is an explicitly requested per-run inspection bundle intended for
|
||||
developers and troubleshooting. It is off by default. When enabled, one bundle
|
||||
contains both redacted run summaries and detailed stage and LLM traces. The
|
||||
internal distinction between a safe summary and a sensitive trace remains, but
|
||||
there is one public enablement and location model.
|
||||
|
||||
Debug data is never a cache input and has no automatic retention policy.
|
||||
Notarius does not create a debug directory unless debug is requested, and it
|
||||
does not automatically delete a requested bundle. Credentials remain redacted
|
||||
at every level, while the bundle as a whole is treated as potentially sensitive
|
||||
because traces may contain source, reference, prompt, model-response, and
|
||||
intermediate artifact content.
|
||||
|
||||
Concise progress, warnings, and failures continue to use stdout or stderr. A
|
||||
run without debug may fail without producing a filesystem inspection record.
|
||||
|
||||
Exact configuration fields, CLI flags, default paths, layouts, compatibility
|
||||
handling, and migration mechanics are configuration and operational contracts
|
||||
rather than part of this decision.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Keep workspace, diagnostics, checkpoints, debug, and chunk-plan cache as
|
||||
separate public concepts. This preserves compatibility and the current safe
|
||||
default-on failure records, but retains overlapping configuration and asks
|
||||
operators to reason about implementation-specific categories.
|
||||
- Keep diagnostics as an always-available redacted operational surface and use
|
||||
debug only for sensitive traces. This distinction is useful for a daemon or
|
||||
managed service with an operational logging contract, but the current CLI can
|
||||
report concise failures on stderr and provide inspection data when explicitly
|
||||
requested.
|
||||
- Put all non-output state beneath one physical root. This minimizes path
|
||||
configuration, but couples reconstructible caches to per-run inspection data
|
||||
and couples cache families whose identity, sensitivity, and cleanup policies
|
||||
differ.
|
||||
- Treat checkpoints as durable run state rather than cache. This emphasizes
|
||||
resumability, but checkpoints are derived, compatibility-checked data that may
|
||||
be deleted and recomputed. Cache more accurately describes their lifecycle.
|
||||
|
||||
## Consequences
|
||||
|
||||
The operator model becomes smaller: normal runs produce output and may use
|
||||
cache; developers explicitly request debug. Public configuration no longer
|
||||
exposes a workspace or overlapping diagnostics and debug systems.
|
||||
|
||||
The implementation retains separate collaborators and serializers where their
|
||||
security or lifecycle boundaries differ. Redacted summaries remain useful as
|
||||
the index to a debug bundle, and chunk plans and checkpoints retain separate
|
||||
stores even though both are cache.
|
||||
|
||||
Existing configuration, environment variables, flags, examples, and
|
||||
documentation require a deliberate compatibility transition. Default-on
|
||||
diagnostic directories disappear, so failures without debug are inspectable
|
||||
only through stderr and any durable output completed before the failure.
|
||||
|
||||
Debug becomes easier to request and substantially more complete, but enabling
|
||||
it creates sensitive files that the operator must protect and remove. Cache
|
||||
cleanup is recoverable but may repeat expensive work, while deleting output is
|
||||
data loss from the user's perspective.
|
||||
50
docs/adr/0007-separate-checkpoint-recording-from-reuse.md
Normal file
50
docs/adr/0007-separate-checkpoint-recording-from-reuse.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# ADR-0007: Separate checkpoint recording from reuse
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-07-19
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0006 made checkpoint I/O conditional on an explicit `--resume` invocation.
|
||||
That policy requires an operator to anticipate the need for recovery before a
|
||||
run begins. A failed ordinary run cannot reuse completed work because it did not
|
||||
record checkpoints.
|
||||
|
||||
Recording reconstructible state and authorizing reuse are separate operational
|
||||
decisions. Recording consumes storage and retains sensitive derived application
|
||||
data, while reuse may change which module operations execute during a run.
|
||||
|
||||
## Decision
|
||||
|
||||
ADR-0006's separation of output, cache, and debug surfaces remains in effect;
|
||||
this decision supersedes only its checkpoint invocation policy.
|
||||
|
||||
Checkpoint recording is controlled by an explicit persistent Boolean
|
||||
configuration setting and remains disabled by default. When recording is
|
||||
enabled, every run records checkpoint transitions and reusable approved stage
|
||||
results.
|
||||
|
||||
Checkpoint loading remains an invocation policy. Only a run with `--resume`
|
||||
loads and reuses compatible completed work. A recording-enabled run without
|
||||
`--resume` executes every stage normally and never loads checkpoints. A resume
|
||||
request while recording is disabled is rejected.
|
||||
|
||||
The existing checkpoint identities, compatibility rules, payload format,
|
||||
filesystem root behavior, and pipeline collaborator contracts remain unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Continue coupling reads and writes to `--resume`. This is safe by default but
|
||||
prevents recovery unless resume was anticipated on the earlier run.
|
||||
- Always record checkpoints. This maximizes recovery but creates potentially
|
||||
sensitive state without explicit operator consent.
|
||||
- Add a multi-value recording policy. This preserves the old behavior as an
|
||||
option but adds configuration complexity without a current need.
|
||||
|
||||
## Consequences
|
||||
|
||||
Operators can opt into recovery-ready runs while keeping checkpoint reuse
|
||||
explicit. Enabled successful, rejected, and failed runs may all leave sensitive
|
||||
checkpoint state, so operators remain responsible for access and retention.
|
||||
Disabled configurations perform no checkpoint I/O, and `--resume` requires the
|
||||
operator to enable recording first.
|
||||
50
docs/adr/0008-ordered-pipeline-steps.md
Normal file
50
docs/adr/0008-ordered-pipeline-steps.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# ADR-0008: Bounded ordered pipeline steps and explicit artifact references
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-07-21
|
||||
|
||||
## Context
|
||||
|
||||
Notarius currently models one pipeline-wide input, chunking plan, artifact
|
||||
lanes, and output boundary. Some workflows need a deterministic handoff from
|
||||
one set of normalized artifacts to a later set of artifacts, such as using
|
||||
extracted NPC records while grounding later combat events. The workflow needs
|
||||
an explicit topology without turning the pipeline into a general-purpose
|
||||
workflow engine.
|
||||
|
||||
## Decision
|
||||
|
||||
Add an ordered collection of pipeline steps. Each step owns one or more
|
||||
artifact lanes, and lanes within a step retain the existing independent
|
||||
execution model. The pipeline continues to have one input, chunk plan, output,
|
||||
and failure boundary. Steps are barriers: a later step may consume only
|
||||
normalized artifacts from an earlier step.
|
||||
|
||||
Generated references use an explicit step-and-lane selector. Reference slots
|
||||
declare the generated artifact kinds and media types they accept. The resolver
|
||||
validates the topology, ordering, lane identity, artifact kind, schema, and
|
||||
codec compatibility before execution. External references remain supported as
|
||||
path sources, and the legacy top-level artifact map is interpreted as an
|
||||
implicit `default` step.
|
||||
|
||||
Pipeline-level references may not select generated artifacts. General DAGs,
|
||||
branches, loops, conditional execution, joins, and inferred dependencies are
|
||||
not part of this model.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- A general DAG would provide more flexibility but would also require a new
|
||||
scheduler, lifecycle model, failure semantics, and provenance model.
|
||||
- Separate pipeline runs connected through filesystem paths would lose the
|
||||
static topology and typed compatibility checks.
|
||||
- Inferring dependencies from module or lane names would make ordering and
|
||||
configuration errors difficult to detect reliably.
|
||||
|
||||
## Consequences
|
||||
|
||||
The resolved pipeline has a deterministic, inspectable topology and can
|
||||
include it in its identity digest. Configuration validation can reject invalid
|
||||
generated bindings before any work begins. Existing single-step profiles keep
|
||||
their behavior through the implicit `default` step. Execution handoff and
|
||||
multi-step scheduling require follow-up work in the runner and checkpoint
|
||||
layers.
|
||||
@@ -0,0 +1,98 @@
|
||||
# ADR-0009: Prefer minimal evidence-grounded extraction artifacts
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-07-22
|
||||
|
||||
## Context
|
||||
|
||||
Notarius is intended to extract structured facts from source material. Several
|
||||
early D&D artifacts grew to include descriptive prose, inferred relationships,
|
||||
immediate outcomes, summaries, and other enrichment alongside the facts that
|
||||
identify an event or entity. Those fields make one model call responsible for
|
||||
both extraction and synthesis.
|
||||
|
||||
In practice, the richer contracts have produced overlapping or weakly grounded
|
||||
fields and have made structurally valid, semantically coherent output harder for
|
||||
cost-effective smaller models. They also increase prompt size, validation and
|
||||
normalization policy, durable schema surface, downstream coupling, and the
|
||||
number of claims whose provenance must be evaluated.
|
||||
|
||||
The application needs a consistent rule for deciding what belongs in an
|
||||
extractor before redesigning the current D&D spell, NPC, and combat-turn
|
||||
contracts or adding new artifact families.
|
||||
|
||||
## Decision
|
||||
|
||||
An extraction module answers one narrowly stated question and returns the
|
||||
smallest durable structured artifact that usefully answers it.
|
||||
|
||||
Every model-produced field in an extraction artifact must:
|
||||
|
||||
- be necessary to answer the extractor's stated question or serve a known
|
||||
downstream consumer;
|
||||
- represent a fact or bounded classification that can be supported directly by
|
||||
cited source ranges;
|
||||
- remain independently meaningful without model-generated explanatory prose;
|
||||
and
|
||||
- justify the additional prompt, schema, validation, normalization, and
|
||||
compatibility surface it creates.
|
||||
|
||||
Source references are required provenance for extracted records. Auxiliary
|
||||
references may disambiguate identities or canonical names, but they do not
|
||||
establish source facts and are not copied into evidence.
|
||||
|
||||
Extraction artifacts do not include narrative summaries, general analysis,
|
||||
speculative enrichment, inferred biography or relationships, or redundant
|
||||
free-text descriptions by default. When such output has a demonstrated use, it
|
||||
belongs in an explicitly named extraction, classification, enrichment, or
|
||||
analysis module with its own contract and evidence policy.
|
||||
|
||||
Occurrence-level facts are not forced into entity-level attributes. A fact
|
||||
that can change between encounters, such as an NPC's role in a scene, belongs
|
||||
on an occurrence artifact rather than as one scalar property of a normalized
|
||||
NPC registry entry.
|
||||
|
||||
Deterministic mapping and normalization may assign application-owned
|
||||
identifiers, canonicalize known catalog values, order and deduplicate evidence,
|
||||
and collapse records under an explicit identity rule. They must not manufacture
|
||||
removed descriptive fields or synthesize missing claims to satisfy an older
|
||||
contract.
|
||||
|
||||
This is a default design rule, not a prohibition on rich artifacts. A richer
|
||||
field is appropriate when its consumer, evidence semantics, and ownership are
|
||||
explicit.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Keep rich schemas and improve prompts or use larger models. This retains
|
||||
potentially convenient prose but does not resolve overlapping field
|
||||
responsibilities, weak provenance, higher cost, or unnecessary downstream
|
||||
coupling.
|
||||
- Make enrichment fields optional. This reduces rejection pressure but leaves
|
||||
ambiguous artifact semantics and inconsistent records, and many strict
|
||||
structured-output providers still require nullable placeholders.
|
||||
- Keep minimal private LLM schemas while preserving rich durable artifacts.
|
||||
Deterministic code would have to invent, default, or separately derive the
|
||||
missing fields, hiding synthesis behind the extraction boundary.
|
||||
- Use one broad session-analysis module. This reduces the number of lanes but
|
||||
couples unrelated facts, schemas, retries, evaluation, and downstream
|
||||
consumers into one model call.
|
||||
|
||||
## Consequences
|
||||
|
||||
Extraction prompts and response schemas become smaller, more focused, and more
|
||||
suitable for lower-cost models. Artifacts carry fewer unsupported claims, and
|
||||
their evidence and validation policies become easier to explain and evaluate.
|
||||
Independent extractors can evolve, retry, and be consumed without requiring
|
||||
unrelated enrichment.
|
||||
|
||||
Some descriptive convenience fields will disappear from primary artifacts.
|
||||
Consumers that genuinely need them may require a separate module and explicit
|
||||
pipeline step. Entity registries may no longer resolve aliases or relationships
|
||||
unless a dedicated, evidence-grounded capability supplies them.
|
||||
|
||||
Removing durable fields is a schema compatibility change. Each affected
|
||||
artifact requires an explicit version and reference policy; private prompt
|
||||
changes alone are insufficient. Current-behavior integration and internal
|
||||
documentation must change with implementation, while the roadmap owns the
|
||||
proposed contract until then.
|
||||
256
docs/cli.md
256
docs/cli.md
@@ -3,26 +3,15 @@
|
||||
This is the canonical reference for the implemented Notarius command-line
|
||||
interface.
|
||||
|
||||
## Quick Run
|
||||
|
||||
```sh
|
||||
NOTARIUS_LLM_DEFAULT_BASE_URL=http://127.0.0.1:8080/v1 \
|
||||
NOTARIUS_LLM_DEFAULT_MODEL=your-model \
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config examples/dnd-spells.config.yml \
|
||||
--input examples/seriatim-minimal-transcript.json
|
||||
```
|
||||
|
||||
Set `NOTARIUS_LLM_DEFAULT_API_KEY` if the OpenAI-compatible provider requires
|
||||
a bearer token.
|
||||
For the minimal end-to-end invocation, see the [README](../README.md).
|
||||
|
||||
## Commands
|
||||
|
||||
```text
|
||||
notarius help
|
||||
notarius run <pipeline-id> --input path/to/source.json [--config path/to/config.yml] [--only lane-a,lane-b]
|
||||
notarius config validate --config path/to/config.yml [--pipeline pipeline-id] [--only lane-a,lane-b]
|
||||
notarius pipelines list --config path/to/config.yml [--json]
|
||||
notarius run <pipeline-id> --input path/to/source.json [--config path/to/config.yml] [--only lane-a,lane-b] [--chunk_cache auto|bypass|refresh] [--output-dir path] [--resume] [--recompute-step step-id] [--debug [--debug-dir path]] [--llm-profile id] [--session-id id] [--reference selector=path] [--without-reference selector]
|
||||
notarius config validate [--config path/to/config.yml] [--pipeline pipeline-id] [--only lane-a,lane-b]
|
||||
notarius pipelines list [--config path/to/config.yml] [--json]
|
||||
```
|
||||
|
||||
Running `notarius` with no arguments, `notarius help`, `notarius --help`, or
|
||||
@@ -36,26 +25,193 @@ file.
|
||||
Flags:
|
||||
|
||||
- `--input path`: required source input file.
|
||||
- `--config path`: config file path. If omitted, Notarius checks
|
||||
`NOTARIUS_CONFIG`, then `/usr/local/etc/notarius/config.yml`.
|
||||
- `--config path`: config file path. If omitted, Notarius uses the discovery
|
||||
rules in [Configuration](config.md#discovery).
|
||||
- `--only lane-a,lane-b`: run only the named artifact lanes. Values are
|
||||
comma-separated and must be non-empty.
|
||||
- `--output-dir path`: output root. The run writes to `<path>/<run-id>/`.
|
||||
Defaults to `./notarius-output`.
|
||||
- `--diagnostics-dir path`: diagnostics work directory override for this
|
||||
invocation.
|
||||
- `--llm-profile id`: override every effective module binding to use one LLM
|
||||
profile.
|
||||
comma-separated and must be non-empty. This retains its existing behavior for
|
||||
implicit single-step pipelines; explicit multi-step pipelines reject it
|
||||
rather than inferring dependency closure.
|
||||
- `--resume`: request checkpoint reuse for this invocation. Checkpoint recording
|
||||
must be enabled in configuration. See
|
||||
[Operations](operations.md#checkpoint-cache) for prerequisites and reuse
|
||||
behavior.
|
||||
- `--recompute-step step-id`: with `--resume` and checkpoint recording enabled,
|
||||
force the named ordered step and every transitive dependent lane to execute.
|
||||
Compatible required predecessors and unrelated lanes remain reusable. The
|
||||
value may identify an explicit step or the implicit single-step ID `default`;
|
||||
it cannot be combined with `--only`.
|
||||
- `--chunk_cache auto|bypass|refresh`: select chunk-plan reuse for this
|
||||
invocation. `auto` reuses a valid plan by canonical source digest, `bypass`
|
||||
performs no plan-cache I/O, and `refresh` regenerates and replaces a valid
|
||||
plan only after chunk validation succeeds. See
|
||||
[Configuration](config.md#state-surfaces) for the persistent setting, precedence,
|
||||
and cache-root selection.
|
||||
- `--output-dir path`: output root. Defaults to `./notarius-output`.
|
||||
- `--debug`: allocate and retain one debug bundle for this invocation.
|
||||
- `--debug-dir path`: debug-bundle root override. This flag requires `--debug`.
|
||||
- `--llm-profile id`: override every effective LLM-capable pipeline module
|
||||
binding with one Scriptorium profile ID. Validator-specific profiles are not
|
||||
overridden.
|
||||
- `--session-id id`: pass a stable prompt session identifier through LLM-backed
|
||||
module calls.
|
||||
- `--reference selector=path`: bind a reference path to a chunk, extractor,
|
||||
merger, or normalizer reference slot. Repeatable.
|
||||
- `--without-reference selector`: remove a configured optional reference binding.
|
||||
Repeatable. It accepts the same selector forms as `--reference`, without
|
||||
`=path`.
|
||||
|
||||
On success, the command prints the completed pipeline ID, approved and rejected
|
||||
artifact counts, and the output directory. If the run completes with warnings,
|
||||
the warning count is printed to stderr.
|
||||
On success, the command prints the completed pipeline ID, normalized output and
|
||||
rejected output counts, and the output directory. A debug-enabled run also
|
||||
prints `debug=<bundle-path>`. If the run completes with warnings, the warning
|
||||
count is printed to stderr.
|
||||
|
||||
For durable output, diagnostics, retention, and failure inspection, see
|
||||
[Operations](operations.md).
|
||||
Reference flags are external file bindings resolved against selected chunk,
|
||||
extractor, merger, and normalizer targets before the run starts. Generated
|
||||
artifact bindings are configured in ordered steps and cannot be introduced by a
|
||||
CLI path flag. Flat slot names are accepted only
|
||||
when exactly one selected target declares that slot. For configured reference
|
||||
bindings, precedence, path resolution, and validation, see
|
||||
[Configuration](config.md#pipelines).
|
||||
|
||||
The current `run` command requires the resolved pipeline to use exactly one
|
||||
distinct LLM profile after defaults and overrides are applied.
|
||||
`--reference` binds or replaces one slot for one selected target. Selectors are:
|
||||
|
||||
- `slot=path`: valid when exactly one selected target declares `slot`;
|
||||
- `chunk.slot=path`: target the chunker;
|
||||
- `merge.slot=path`: valid when exactly one selected merger declares `slot`;
|
||||
- `lane.slot=path`: valid when exactly one selected extractor, merger, or
|
||||
normalizer in that lane declares `slot`;
|
||||
- `lane.extract.slot=path`: target a lane extractor;
|
||||
- `lane.merge.slot=path`: target a lane merger;
|
||||
- `lane.normalize.slot=path`: target a lane normalizer.
|
||||
|
||||
Use `slot=path` when the selected targets declare the slot unambiguously:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config examples/dnd-minimal.config.yml \
|
||||
--input examples/seriatim-minimal-transcript.json \
|
||||
--reference roster=./campaign-roster.txt
|
||||
```
|
||||
|
||||
Use an explicit selector when multiple selected targets declare the same slot or
|
||||
when you want to target a specific target:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config examples/dnd-minimal.config.yml \
|
||||
--input examples/seriatim-minimal-transcript.json \
|
||||
--reference spells.extract.glossary=./campaign-glossary.txt
|
||||
```
|
||||
|
||||
For the maintained NPC-grounded workflow, use the explicit ordered pipeline.
|
||||
The first step produces the normalized NPC artifact; the second step receives
|
||||
it in memory and fans it out to spell extraction, combat extraction, and combat
|
||||
normalization:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config examples/dnd-complete.config.yml \
|
||||
--input examples/seriatim-minimal-transcript.json \
|
||||
--output-dir ./npc-grounded-output
|
||||
```
|
||||
|
||||
The generated NPC content remains contextual grounding, not spell or combat
|
||||
evidence. It is represented in manifests and debug summaries by bounded
|
||||
identity and producer provenance, not by payload content or a filesystem path.
|
||||
|
||||
The same grammar can target chunk, merge, and normalize slots when the configured
|
||||
modules declare them:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config path/to/config.yml \
|
||||
--input examples/seriatim-minimal-transcript.json \
|
||||
--reference chunk.scene_guide=./campaign-scenes.txt \
|
||||
--reference spells.merge.merge_notes=./merge-notes.txt \
|
||||
--reference spells.normalize.normalization_notes=./normalization-notes.txt
|
||||
```
|
||||
|
||||
Use `--without-reference` to remove a configured optional binding for a run:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config examples/dnd-minimal.config.yml \
|
||||
--input examples/seriatim-minimal-transcript.json \
|
||||
--without-reference glossary
|
||||
```
|
||||
|
||||
Use `--session-id` when an external orchestrator needs all prompt calls from one
|
||||
run to share an identifier:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config examples/dnd-minimal.config.yml \
|
||||
--input examples/seriatim-minimal-transcript.json \
|
||||
--session-id campaign-17-session-04
|
||||
```
|
||||
|
||||
When `cache.checkpoints.enabled` is `true`, runs record checkpoints whether or
|
||||
not `--resume` is present. Add the resume flag to load and reuse compatible
|
||||
recorded work; using it while checkpoint recording is disabled is an error:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config examples/dnd-minimal.config.yml \
|
||||
--input examples/seriatim-minimal-transcript.json \
|
||||
--resume
|
||||
```
|
||||
|
||||
To selectively rerun one ordered step and its dependent lanes, use the step ID
|
||||
from the configuration. The selected step and dependents are reported as
|
||||
`forced_recompute`; reusable predecessors are reported as `reused`:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config examples/dnd-complete.config.yml \
|
||||
--input examples/seriatim-minimal-transcript.json \
|
||||
--resume --recompute-step extract-events
|
||||
```
|
||||
|
||||
Checkpoint decisions use these categories: `reused`, `executed`,
|
||||
`forced_recompute`, and `dependency_invalidated`. The reason code and bounded
|
||||
detail identify the decision without exposing reference content, local paths,
|
||||
or secrets. `--recompute-step` requires checkpoint recording and `--resume`;
|
||||
unknown step IDs, empty values, and combinations with `--only` are rejected.
|
||||
The operator meanings of checkpoint reason codes are maintained in
|
||||
[Operations](operations.md#resume-and-selective-recompute).
|
||||
|
||||
Use `--debug` to retain the redacted summary and trace bundle for one run. The
|
||||
bundle is allocated before pipeline resolution; once allocated, its path is
|
||||
also printed to stderr if the command fails. Debug-write failures cause exit
|
||||
code `1`.
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config examples/dnd-minimal.config.yml \
|
||||
--input examples/seriatim-minimal-transcript.json \
|
||||
--debug --debug-dir ./notarius-debug
|
||||
```
|
||||
|
||||
Use `refresh` when intentionally replacing the cached plan for the same source:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config examples/dnd-minimal.config.yml \
|
||||
--input examples/seriatim-minimal-transcript.json \
|
||||
--chunk_cache refresh
|
||||
```
|
||||
|
||||
Use `bypass` for a one-off run that must not inspect or create plan-cache state:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config examples/dnd-minimal.config.yml \
|
||||
--input examples/seriatim-minimal-transcript.json \
|
||||
--chunk_cache bypass
|
||||
```
|
||||
|
||||
`--diagnostics-dir` has been removed. For checkpoint behavior, durable output,
|
||||
debug-bundle lifecycle, and failure inspection, see [Operations](operations.md).
|
||||
|
||||
## `config validate`
|
||||
|
||||
@@ -63,8 +219,8 @@ distinct LLM profile after defaults and overrides are applied.
|
||||
|
||||
Flags:
|
||||
|
||||
- `--config path`: config file path. If omitted, discovery uses
|
||||
`NOTARIUS_CONFIG`, then `/usr/local/etc/notarius/config.yml`.
|
||||
- `--config path`: config file path. If omitted, Notarius uses the discovery
|
||||
rules in [Configuration](config.md#discovery).
|
||||
- `--pipeline pipeline-id`: additionally resolve one configured pipeline against
|
||||
the production module catalog.
|
||||
- `--only lane-a,lane-b`: validate resolution for selected artifact lanes. This
|
||||
@@ -74,10 +230,10 @@ Examples:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius config validate \
|
||||
--config examples/dnd-spells.config.yml
|
||||
--config examples/dnd-minimal.config.yml
|
||||
|
||||
go run ./cmd/notarius config validate \
|
||||
--config examples/dnd-spells.config.yml \
|
||||
--config examples/dnd-minimal.config.yml \
|
||||
--pipeline dnd-session \
|
||||
--only spells
|
||||
```
|
||||
@@ -88,18 +244,18 @@ go run ./cmd/notarius config validate \
|
||||
|
||||
Flags:
|
||||
|
||||
- `--config path`: config file path. If omitted, discovery uses
|
||||
`NOTARIUS_CONFIG`, then `/usr/local/etc/notarius/config.yml`.
|
||||
- `--config path`: config file path. If omitted, Notarius uses the discovery
|
||||
rules in [Configuration](config.md#discovery).
|
||||
- `--json`: print `{"pipelines":[...]}` instead of one ID per line.
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius pipelines list \
|
||||
--config examples/dnd-spells.config.yml
|
||||
--config examples/dnd-minimal.config.yml
|
||||
|
||||
go run ./cmd/notarius pipelines list \
|
||||
--config examples/dnd-spells.config.yml \
|
||||
--config examples/dnd-minimal.config.yml \
|
||||
--json
|
||||
```
|
||||
|
||||
@@ -107,23 +263,11 @@ go run ./cmd/notarius pipelines list \
|
||||
|
||||
- `0`: command succeeded.
|
||||
- `1`: command syntax was valid, but loading config, resolving modules, running
|
||||
the pipeline, calling the provider, writing output, or writing diagnostics
|
||||
failed.
|
||||
the pipeline, calling the provider, writing output, or writing a requested
|
||||
debug bundle failed.
|
||||
- `2`: command syntax was invalid, a command was unknown, a required argument
|
||||
was missing, or a flag value was malformed.
|
||||
|
||||
## Implemented Production Pipeline Modules
|
||||
|
||||
The production CLI currently registers these module keys:
|
||||
|
||||
- input: `seriatim`
|
||||
- chunk: `generic`
|
||||
- extract: `dnd/spells`
|
||||
- merge: `appendorder`
|
||||
- normalize: `noop`
|
||||
- output: `json`
|
||||
|
||||
The production CLI does not currently register validator modules.
|
||||
|
||||
For YAML structure, defaults, environment overrides, and module binding syntax,
|
||||
see [Configuration](config.md).
|
||||
For YAML structure, defaults, Scriptorium profile sources, environment
|
||||
overrides, and selectable module and validator keys, see
|
||||
[Configuration](config.md).
|
||||
|
||||
754
docs/config.md
754
docs/config.md
@@ -2,137 +2,296 @@
|
||||
|
||||
This is the canonical reference for implemented Notarius configuration.
|
||||
|
||||
Notarius reads YAML config files with `version: 1`. File config is applied over
|
||||
built-in defaults, then environment overrides are applied.
|
||||
Notarius reads YAML config files with `version: 3`. File configuration is
|
||||
applied over built-in defaults, then environment overrides are applied. Explicit
|
||||
CLI overrides are applied last where the command supports them.
|
||||
|
||||
## Discovery
|
||||
|
||||
Commands that accept `--config` load configuration in this order:
|
||||
Commands that load configuration use this order:
|
||||
|
||||
1. the `--config` path, when provided;
|
||||
1. an explicit path supplied through the CLI, when provided;
|
||||
2. `NOTARIUS_CONFIG`, when set to a non-empty path;
|
||||
3. `/usr/local/etc/notarius/config.yml`.
|
||||
|
||||
If none is available, the command fails with a config file not found error.
|
||||
The explicit-path option is defined in the [CLI reference](cli.md).
|
||||
|
||||
## Minimal Example
|
||||
## Maintained Examples
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
llm_profiles:
|
||||
default:
|
||||
provider: openai-compatible
|
||||
base_url: http://127.0.0.1:8080/v1
|
||||
model: your-model
|
||||
pipelines:
|
||||
dnd-session:
|
||||
input: seriatim
|
||||
chunk:
|
||||
module: generic
|
||||
options:
|
||||
max_units: 50
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
```
|
||||
- [Minimal D&D configuration](../examples/dnd-minimal.config.yml) shows the
|
||||
shortest useful single-step, single-lane pipeline.
|
||||
- [Complete D&D configuration](../examples/dnd-complete.config.yml) shows scene
|
||||
chunking, ordered steps, all five D&D artifact lanes, a generated NPC
|
||||
handoff, campaign and spell-catalog references, checkpoint storage, and
|
||||
chunk-map output.
|
||||
|
||||
The maintained fixture is [examples/dnd-spells.config.yml](../examples/dnd-spells.config.yml).
|
||||
Both are complete version 3 files. The fragments below illustrate individual
|
||||
fields and are not alternate complete configurations.
|
||||
|
||||
## Top-Level Fields
|
||||
|
||||
- `version`: required. The only supported value is `1`.
|
||||
- `llm_profiles`: optional map of LLM profile IDs to profile settings.
|
||||
- `version`: required. The only supported value is `3`.
|
||||
- `scriptorium`: optional Scriptorium profile source settings.
|
||||
- `pipelines`: optional map of pipeline IDs to pipeline definitions.
|
||||
- `concurrency`: optional global concurrency settings.
|
||||
- `diagnostics`: optional diagnostics settings.
|
||||
- `output`: optional durable output placement.
|
||||
- `cache`: optional chunk-plan and checkpoint cache placement.
|
||||
- `debug`: optional debug-bundle placement. It does not enable debug capture.
|
||||
|
||||
Unknown YAML fields are rejected.
|
||||
Unknown YAML fields are rejected. The removed top-level `llm_profiles` field is
|
||||
rejected; execution profiles now come from Scriptorium.
|
||||
|
||||
## Defaults
|
||||
|
||||
Built-in defaults:
|
||||
|
||||
```yaml
|
||||
llm_profiles:
|
||||
default:
|
||||
provider: openai-compatible
|
||||
timeout: 600
|
||||
max_retries: 3
|
||||
max_concurrency: 1
|
||||
concurrency:
|
||||
total_llm: 1
|
||||
diagnostics:
|
||||
work_dir: /tmp/notarius
|
||||
retention: auto
|
||||
```
|
||||
- `concurrency.total_llm`: `1`
|
||||
- `concurrency.stage_workers.extract`: effective `concurrency.total_llm`
|
||||
- `output.directory`: `./notarius-output`
|
||||
- `cache.chunk_plans.mode`: `auto`
|
||||
- `cache.chunk_plans.directory`: unset, selecting
|
||||
`<os.UserCacheDir>/notarius/chunk-plans`
|
||||
- `cache.checkpoints.enabled`: `false`
|
||||
- `cache.checkpoints.directory`: unset, selecting
|
||||
`<os.UserCacheDir>/notarius/checkpoints`
|
||||
- `debug.directory`: `./notarius-debug`
|
||||
|
||||
No pipelines are built in. A run requires a configured pipeline.
|
||||
|
||||
## LLM Profiles
|
||||
If `scriptorium` is omitted, Notarius uses Scriptorium's built-in profile
|
||||
catalog. Prompt definitions may also name default profile IDs. The current D&D
|
||||
scene and spell prompts default to the built-in `mistral-small-3` profile when a
|
||||
module binding does not set `llm_profile`. That built-in profile reads its
|
||||
credential from `OPENROUTER_API_KEY`.
|
||||
|
||||
Each `llm_profiles` entry may contain:
|
||||
## Scriptorium Profiles
|
||||
|
||||
- `provider`: optional provider key. Empty means `openai-compatible`; any other
|
||||
non-empty value must be `openai-compatible`.
|
||||
- `base_url`: provider base URL. Required for actual LLM calls.
|
||||
- `model`: provider model name. Required for actual LLM calls.
|
||||
- `api_key_env`: environment variable name to read for the API key.
|
||||
- `timeout`: request timeout as whole seconds or a Go-style duration string such
|
||||
as `10m`.
|
||||
- `max_retries`: retry count for provider calls. Must be zero or greater.
|
||||
- `max_concurrency`: per-profile LLM concurrency. Must be zero or greater; when
|
||||
zero, Notarius uses `concurrency.total_llm`.
|
||||
`scriptorium` fields:
|
||||
|
||||
Raw API keys are not accepted as file config fields. Use `api_key_env` or an
|
||||
environment override.
|
||||
- `profile_dir`: optional directory containing Scriptorium profile YAML files.
|
||||
- `profile_file`: optional Scriptorium profile YAML file.
|
||||
|
||||
`profile_dir` and `profile_file` are mutually exclusive. Custom profiles
|
||||
overlay Scriptorium built-in profiles by profile ID.
|
||||
|
||||
Scriptorium profile files use Scriptorium's profile schema. A minimal profile
|
||||
looks like:
|
||||
|
||||
```yaml
|
||||
id: local-fast
|
||||
endpoint: http://127.0.0.1:8080/v1
|
||||
model: your-model
|
||||
api_key_env: SCRIPTORIUM_API_KEY
|
||||
timeout_seconds: 180
|
||||
```
|
||||
|
||||
Notarius does not accept raw API keys in Notarius config. For file-backed
|
||||
Scriptorium profiles, store the environment variable name in `api_key_env` and
|
||||
set that variable in the run environment. Scriptorium rejects raw `api_key`
|
||||
fields in profile YAML.
|
||||
|
||||
## Environment Overrides
|
||||
|
||||
These environment variables are applied after the config file:
|
||||
|
||||
- `NOTARIUS_CONFIG`: config discovery path.
|
||||
- `NOTARIUS_LLM_DEFAULT_API_KEY`: API key for the `default` LLM profile.
|
||||
- `NOTARIUS_LLM_DEFAULT_BASE_URL`: base URL for the `default` LLM profile.
|
||||
- `NOTARIUS_LLM_DEFAULT_MODEL`: model for the `default` LLM profile.
|
||||
- `NOTARIUS_LLM_DEFAULT_TIMEOUT_SECONDS`: integer timeout seconds for the
|
||||
`default` LLM profile.
|
||||
- `NOTARIUS_LLM_DEFAULT_MAX_RETRIES`: integer retry count for the `default` LLM
|
||||
profile.
|
||||
- `NOTARIUS_LLM_DEFAULT_MAX_CONCURRENCY`: integer max concurrency for the
|
||||
`default` LLM profile.
|
||||
- `NOTARIUS_TOTAL_LLM_CONCURRENCY`: integer global LLM concurrency.
|
||||
- `NOTARIUS_WORK_DIR`: diagnostics work directory.
|
||||
- `NOTARIUS_DIAGNOSTICS_RETENTION`: diagnostics retention mode.
|
||||
- `NOTARIUS_STAGE_WORKERS_EXTRACT`: integer extract worker limit.
|
||||
- `NOTARIUS_OUTPUT_DIR`: durable output root.
|
||||
- `NOTARIUS_CACHE_CHUNK_PLANS_MODE`: chunk-plan cache mode.
|
||||
- `NOTARIUS_CACHE_CHUNK_PLANS_DIR`: chunk-plan cache root.
|
||||
- `NOTARIUS_CACHE_CHECKPOINTS_DIR`: checkpoint cache root.
|
||||
- `NOTARIUS_DEBUG_DIR`: debug-bundle root.
|
||||
|
||||
Integer environment values must parse as base-10 integers.
|
||||
Integer environment values must parse as base-10 integers. Directory overrides
|
||||
must be non-empty after trimming. Cache-directory fields in a file may be
|
||||
empty, which deliberately selects the corresponding per-user default.
|
||||
|
||||
The removed `NOTARIUS_LLM_DEFAULT_*` variables are not read. Configure provider
|
||||
endpoint, model, and credential environment variable names through Scriptorium
|
||||
profiles.
|
||||
|
||||
## Concurrency
|
||||
|
||||
`concurrency` fields:
|
||||
|
||||
- `total_llm`: positive integer ceiling on concurrent provider calls.
|
||||
- `stage_workers`: optional map of framework worker limits. The only supported
|
||||
key is `extract`.
|
||||
|
||||
`stage_workers.extract` defaults to the effective `total_llm` value after file
|
||||
and environment precedence. It must be between `1` and `total_llm`, inclusive.
|
||||
Unknown or empty stage-worker keys are rejected. The environment override
|
||||
`NOTARIUS_STAGE_WORKERS_EXTRACT` takes precedence over the file value, as does
|
||||
`NOTARIUS_TOTAL_LLM_CONCURRENCY` for the global ceiling.
|
||||
|
||||
The worker value is present in effective and redacted configuration. It bounds
|
||||
the fixed run-wide extract pool and its bounded dispatch queue. Extract jobs are
|
||||
submitted by source chunk and then resolved lane; `total_llm` independently
|
||||
bounds actual provider calls made by extracts, retries, and validators.
|
||||
|
||||
## Pipelines
|
||||
|
||||
A pipeline defines the fixed Notarius workflow:
|
||||
|
||||
```text
|
||||
input -> chunk -> extract -> merge -> normalize -> output
|
||||
```
|
||||
A pipeline selects implementations for the fixed workflow defined by
|
||||
[Architecture](policy/architecture.md#system-shape).
|
||||
|
||||
Pipeline fields:
|
||||
|
||||
- `input`: required module binding.
|
||||
- `chunk`: optional module binding. Default module is `generic`.
|
||||
- `artifacts`: required for pipeline resolution. It maps artifact lane IDs to
|
||||
lane definitions.
|
||||
- `artifacts`: the artifact lane map for a single-step pipeline. It is treated
|
||||
as an implicit step with the stable ID `default`.
|
||||
- `steps`: an ordered, non-empty list of step definitions. A pipeline may use
|
||||
`steps` or `artifacts`, but not both. Step IDs must be unique after trimming.
|
||||
- `output`: optional module binding. Default module is `json`.
|
||||
- `references`: optional map of reference slot names to reference paths. These
|
||||
bindings are defaults for eligible pipeline targets that declare the matching
|
||||
slot.
|
||||
|
||||
Each explicit step contains an `id`, an optional `references` map, and a
|
||||
non-empty `artifacts` map. Steps share the pipeline input, chunk plan, worker
|
||||
budget, output encoder, manifest, and failure boundary. Lanes within a step
|
||||
retain the fixed extract, validate, merge, validate, normalize, and validate
|
||||
workflow; the next step starts only after the current step is terminal.
|
||||
|
||||
Generated references use the structured `artifact` source form to identify one
|
||||
accepted normalized lane from an earlier step:
|
||||
|
||||
```yaml
|
||||
pipelines:
|
||||
dnd-npc-grounded:
|
||||
input: seriatim
|
||||
steps:
|
||||
- id: identify-npcs
|
||||
artifacts:
|
||||
npcs:
|
||||
extract: dnd/npcs
|
||||
normalize: dnd/npcs
|
||||
- id: grounded-events
|
||||
references:
|
||||
npcs:
|
||||
artifact:
|
||||
step: identify-npcs
|
||||
lane: npcs
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
normalize: dnd/spells
|
||||
combat:
|
||||
extract: dnd/combat-turns
|
||||
normalize: dnd/combat-turns
|
||||
```
|
||||
|
||||
The generated binding is explicit and typed; it is not inferred from module,
|
||||
lane, or slot names. It may be declared at step scope, applying to every
|
||||
selected target in that step that declares the slot, or at one target's
|
||||
`references` map. A producer may fan out to compatible target slots, but a
|
||||
slot accepts only one producer. A producer must be in an earlier step, and a
|
||||
configured generated dependency is required even when the consumer slot is
|
||||
otherwise optional. Aggregating several producer artifacts is unsupported.
|
||||
|
||||
The producer codec supplies the artifact kind, complete schema identity, media
|
||||
type, canonical content digest, and size used for compatibility and checkpoint
|
||||
dependency checks. Only one accepted normalized artifact crosses the boundary;
|
||||
raw extraction results, rejected output, intermediate values, and validator
|
||||
diagnostics do not. Generated content is supplied in memory and is never
|
||||
represented by a filesystem path.
|
||||
|
||||
Artifact lane fields:
|
||||
|
||||
- `extract`: required module binding.
|
||||
- `merge`: optional module binding. Default module is `appendorder`.
|
||||
- `normalize`: optional module binding. Default module is `noop`.
|
||||
- `validators`: optional list of module bindings. The production CLI currently
|
||||
does not register validator modules.
|
||||
- `validators`: deprecated lane-level validator list. Non-empty lists are
|
||||
rejected; use `extract.validators`, `merge.validators`, or
|
||||
`normalize.validators`.
|
||||
- `references`: optional compatibility alias for extractor reference bindings.
|
||||
Lane bindings override pipeline-level bindings for the same slot.
|
||||
|
||||
`notarius run` and `notarius config validate --pipeline` resolve the pipeline
|
||||
against the production module catalog and fail fast for unknown or incompatible
|
||||
module keys.
|
||||
Commands that resolve a pipeline fail for unknown or incompatible module keys.
|
||||
See [CLI Reference](cli.md) for command syntax.
|
||||
|
||||
Reference bindings are validated against reference slots declared by eligible
|
||||
chunk, extract, merge, and normalize targets during pipeline resolution. Required slots
|
||||
must be bound after config defaults, target-local references, lane-level
|
||||
compatibility bindings, step-local references, and command-line reference
|
||||
overrides are applied. Config-relative paths are resolved relative to the
|
||||
config file; command-line reference paths are resolved relative to the current
|
||||
working directory. Bound files must be UTF-8 text. Reference media types are
|
||||
inferred from file extensions and checked when a module restricts accepted
|
||||
types; unknown extensions use `application/octet-stream`. See [CLI Reference](cli.md#run)
|
||||
for command-line selectors and [Operations](operations.md) for recorded
|
||||
provenance and sensitive-data handling.
|
||||
|
||||
Pipeline-level `references` are defaults. They are valid when at least one
|
||||
eligible target in the full configured pipeline declares the slot, including
|
||||
chunk, extractor, merger, and normalizer targets. During a run, they apply only
|
||||
to the selected targets that declare the slot. For external bindings, a
|
||||
step-local binding overrides a pipeline-level default and a target-local
|
||||
binding retains the existing most-specific precedence. Generated and external
|
||||
bindings may not resolve to the same target slot, and a step-scoped generated
|
||||
binding may not be duplicated by a target-local generated binding.
|
||||
|
||||
```yaml
|
||||
pipelines:
|
||||
dnd-session:
|
||||
input: seriatim
|
||||
references:
|
||||
players: ./campaign/players.txt
|
||||
party: ./campaign/party-roster.txt
|
||||
glossary: ./campaign/glossary.txt
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
```
|
||||
|
||||
Extractor binding `references` are the canonical lane-local location. The
|
||||
legacy lane-level `references` field remains supported as an alias; when both
|
||||
bind the same slot, `extract.references` wins:
|
||||
|
||||
```yaml
|
||||
pipelines:
|
||||
dnd-session:
|
||||
input: seriatim
|
||||
references:
|
||||
glossary: ./campaign/glossary.txt
|
||||
artifacts:
|
||||
spells:
|
||||
references:
|
||||
roster: ./campaign/legacy-roster.txt
|
||||
extract:
|
||||
module: dnd/spells
|
||||
references:
|
||||
party: ./campaign/session-party.txt
|
||||
```
|
||||
|
||||
`chunk.references`, `merge.references`, and `normalize.references` are accepted
|
||||
in object-form bindings. They override pipeline-level defaults for slots
|
||||
declared by that target module. Extractor-local references apply only to the
|
||||
extractor, merger-local references apply only to the merger, and
|
||||
normalizer-local references apply only to the normalizer.
|
||||
|
||||
Target-local reference fields use the same map shape at:
|
||||
|
||||
- `pipelines.<id>.chunk.references`
|
||||
- `pipelines.<id>.artifacts.<lane>.extract.references`
|
||||
- `pipelines.<id>.artifacts.<lane>.merge.references`
|
||||
- `pipelines.<id>.artifacts.<lane>.normalize.references`
|
||||
|
||||
Each binding is valid only when that target module declares the slot.
|
||||
|
||||
Reference source forms are:
|
||||
|
||||
- a scalar string, which is an external file path; or
|
||||
- an object with only `artifact`, containing trimmed `step` and `lane` IDs for
|
||||
an earlier producer lane.
|
||||
|
||||
Pipeline-level references accept only external paths. Generated references are
|
||||
valid at step scope or on a target-local `references` map. Their producer's
|
||||
registered codec is authoritative for schema, media type, and canonical
|
||||
content identity; an external file is not treated as generated merely because
|
||||
its bytes decode as the same artifact.
|
||||
|
||||
## Module Bindings
|
||||
|
||||
@@ -146,20 +305,65 @@ or object form:
|
||||
|
||||
```yaml
|
||||
chunk:
|
||||
module: generic
|
||||
llm_profile: default
|
||||
options:
|
||||
max_units: 50
|
||||
module: dnd/scenes
|
||||
llm_profile: local-fast
|
||||
```
|
||||
|
||||
Binding fields:
|
||||
|
||||
- `module`: module key.
|
||||
- `llm_profile`: optional LLM profile ID. Empty means `default`.
|
||||
- `llm_profile`: optional Scriptorium profile ID. Empty or omitted lets the
|
||||
Scriptorium prompt default select the profile.
|
||||
- `retries`: non-negative retry count for extra runtime attempts after the
|
||||
first attempt. Default: `0`. Supported on `chunk`, `extract`, `merge`, and
|
||||
`normalize` bindings.
|
||||
- `options`: optional module-specific settings.
|
||||
- `references`: optional reference bindings. Supported only for `chunk`,
|
||||
`extract`, `merge`, and `normalize` bindings. `input` and `output` bindings
|
||||
reject this field during validation.
|
||||
- `validators`: optional stage-local validator chain override. Supported only
|
||||
for `chunk`, `extract`, `merge`, and `normalize` bindings. Omit the field to
|
||||
use the production default chain; set `validators: []` to force an empty
|
||||
chain; set a non-empty list to use exactly those validators in configured
|
||||
order.
|
||||
|
||||
The `--llm-profile` run flag overrides every effective module binding to use
|
||||
one configured profile.
|
||||
During resolution, each selected module's registered option validator runs.
|
||||
Production input, chunk, and output bindings reject unknown or invalid options
|
||||
with the affected binding context.
|
||||
|
||||
### JSON Output Options
|
||||
|
||||
The `json` output module accepts only `include_chunk_map`, a boolean that
|
||||
defaults to `false`. When `true`, it adds the accepted pipeline-wide chunk map
|
||||
to the logical output bundle when one exists. It does not create a CLI flag or
|
||||
change output placement.
|
||||
|
||||
```yaml
|
||||
output:
|
||||
module: json
|
||||
options:
|
||||
include_chunk_map: true
|
||||
```
|
||||
|
||||
The payload and index descriptor are defined by the
|
||||
[Accepted Chunk Map contract](integrations/chunk-map.md). See the
|
||||
[complete D&D configuration](../examples/dnd-complete.config.yml) for a
|
||||
copyable pipeline that enables this option.
|
||||
|
||||
Validator bindings use the same shorthand or object module-binding form, but
|
||||
only these fields are supported:
|
||||
|
||||
- `module`: validator key.
|
||||
- `llm_profile`: optional Scriptorium profile ID for LLM-backed validators.
|
||||
- `options`: optional validator-specific settings.
|
||||
|
||||
Validator bindings reject `references`, `retries`, and nested `validators`.
|
||||
During resolution, deterministic validators reject explicit `llm_profile`
|
||||
values.
|
||||
|
||||
Configured LLM-backed validators with explicit `llm_profile` values are
|
||||
validated against the configured Scriptorium profile source. Deterministic
|
||||
production validators do not call the LLM and must not set `llm_profile`.
|
||||
|
||||
## Implemented Production Modules
|
||||
|
||||
@@ -167,10 +371,147 @@ one configured profile.
|
||||
| --- | --- | --- |
|
||||
| input | `seriatim` | Reads Seriatim transcript JSON. |
|
||||
| chunk | `generic` | Splits source units into ordered chunks. |
|
||||
| extract | `dnd/spells` | Extracts `dnd.spell_cast` artifacts. |
|
||||
| merge | `appendorder` | Keeps candidates in append order. |
|
||||
| normalize | `noop` | Passes merged artifacts through unchanged. |
|
||||
| output | `json` | Produces JSON output files. |
|
||||
| chunk | `dnd/scenes` | Uses an LLM to split transcript source units into D&D scenes. |
|
||||
| extract | `dnd/spells` | Extracts typed D&D spell-list artifacts. |
|
||||
| extract | `dnd/npcs` | Extracts typed D&D NPC-list artifacts. |
|
||||
| extract | `dnd/combat-turns` | Extracts typed D&D combat-turn-list artifacts. |
|
||||
| extract | `dnd/npc-interactions` | Extracts typed D&D NPC-interaction-list artifacts from a supplied NPC registry. |
|
||||
| extract | `dnd/scene-descriptions` | Extracts one typed D&D scene description for each accepted chunk. |
|
||||
| merge | `appendorder` | Combines typed artifacts in chunk order. |
|
||||
| normalize | `noop` | Passes merged typed artifacts through unchanged. |
|
||||
| normalize | `dnd/spells` | Deterministically canonicalizes and de-duplicates typed D&D spell-list artifacts. |
|
||||
| normalize | `dnd/npcs` | Deterministically consolidates typed D&D NPC-list artifacts by canonical name and unions exact evidence. |
|
||||
| normalize | `dnd/combat-turns` | Deterministically canonicalizes, orders, and de-duplicates typed D&D combat-turn artifacts. |
|
||||
| normalize | `dnd/npc-interactions` | Canonicalizes registry NPC names, orders interaction occurrences, and removes only exact duplicates. |
|
||||
| normalize | `dnd/scene-descriptions` | Validates, orders, trims, and de-duplicates typed D&D scene-description artifacts. |
|
||||
| output | `json` | Produces JSON output files for normalized `application/json` lanes and can opt in to an accepted chunk map. |
|
||||
|
||||
## Implemented Production Validators
|
||||
|
||||
| Key | Execution | Notes |
|
||||
| --- | --- | --- |
|
||||
| `generic/always_accept` | deterministic | Accepts returned module output. |
|
||||
| `generic/always_reject` | deterministic | Rejects returned module output with reason `always_reject`. |
|
||||
| `generic/valid_json` | deterministic | Rejects payloads that are not syntactically valid JSON. |
|
||||
| `generic/valid_json_schema` | deterministic | Rejects invalid JSON or JSON that does not conform to the module response schema. |
|
||||
| `extract/dnd/spells/shape` | deterministic | Rejects malformed D&D spell-list artifacts. |
|
||||
| `extract/dnd/spells/catalog` | deterministic | Rejects spell-list artifacts containing names outside the effective SRD and overlay catalog. |
|
||||
| `extract/dnd/spells/source_refs` | deterministic | Rejects missing or invalid D&D spell source references. |
|
||||
| `extract/dnd/spells/source_relatedness` | deterministic | Emits warnings when a spell name is not found near its cited source text. |
|
||||
| `extract/dnd/npcs/shape` | deterministic | Rejects malformed D&D NPC-list artifacts. |
|
||||
| `extract/dnd/npcs/source_refs` | deterministic | Rejects missing or invalid D&D NPC source references. |
|
||||
| `extract/dnd/npcs/source_relatedness` | deterministic | Emits warnings when an NPC name is not found near its cited source text. |
|
||||
| `normalize/dnd/npcs/identity` | deterministic | Rejects invalid canonical IDs and duplicate canonical-name or ID ownership. |
|
||||
| `extract/dnd/combat-turns/shape` | deterministic | Rejects malformed D&D combat-turn artifacts. |
|
||||
| `extract/dnd/combat-turns/source_refs` | deterministic | Rejects missing or invalid D&D combat-turn source references. |
|
||||
| `extract/dnd/combat-turns/source_relatedness` | deterministic | Emits warnings when an actor is not found near cited source text. |
|
||||
| `normalize/dnd/combat-turns/invariants` | deterministic | Rejects normalized combat-turn identity, evidence-order, and chronology violations. |
|
||||
| `extract/dnd/npc-interactions/shape` | deterministic | Rejects malformed D&D NPC-interaction-list artifacts. |
|
||||
| `extract/dnd/npc-interactions/registry` | deterministic | Rejects interaction names absent from the supplied NPC registry. |
|
||||
| `extract/dnd/npc-interactions/source_refs` | deterministic | Rejects missing, invalid, or extract-chunk-external D&D interaction source references. |
|
||||
| `extract/dnd/npc-interactions/source_relatedness` | deterministic | Emits bounded warnings when an interaction name is not found in its cited source text. |
|
||||
| `normalize/dnd/npc-interactions/invariants` | deterministic | Rejects normalized interaction identity, evidence-order, and chronology violations. |
|
||||
| `extract/dnd/scene-descriptions/shape` | deterministic | Rejects malformed D&D scene-description-list artifacts. |
|
||||
| `extract/dnd/scene-descriptions/source_refs` | deterministic | Rejects invalid current-source references and extract records not exactly attached to their chunk. |
|
||||
| `extract/dnd/scene-descriptions/source_relatedness` | deterministic | Emits bounded advisory warnings for title or summary text not grounded in its cited transcript range. |
|
||||
| `normalize/dnd/scene-descriptions/invariants` | deterministic | Rejects non-normalized scene order, exact duplicates, and ID or range conflicts. |
|
||||
|
||||
The production default chain for `dnd/spells` is used for both its extract and
|
||||
normalize stages:
|
||||
|
||||
```yaml
|
||||
validators:
|
||||
- generic/valid_json
|
||||
- generic/valid_json_schema
|
||||
- extract/dnd/spells/shape
|
||||
- extract/dnd/spells/catalog
|
||||
- extract/dnd/spells/source_refs
|
||||
- extract/dnd/spells/source_relatedness
|
||||
```
|
||||
|
||||
The production default chain for `dnd/npcs` uses the extraction chain for the
|
||||
extract stage and the identity chain for normalize-stage output:
|
||||
|
||||
```yaml
|
||||
extract:
|
||||
validators:
|
||||
- generic/valid_json
|
||||
- generic/valid_json_schema
|
||||
- extract/dnd/npcs/shape
|
||||
- extract/dnd/npcs/source_refs
|
||||
- extract/dnd/npcs/source_relatedness
|
||||
normalize:
|
||||
validators:
|
||||
- generic/valid_json
|
||||
- generic/valid_json_schema
|
||||
- normalize/dnd/npcs/identity
|
||||
- extract/dnd/npcs/source_refs
|
||||
- extract/dnd/npcs/source_relatedness
|
||||
```
|
||||
|
||||
The production default chains for `dnd/combat-turns` are:
|
||||
|
||||
```yaml
|
||||
extract:
|
||||
validators:
|
||||
- generic/valid_json
|
||||
- generic/valid_json_schema
|
||||
- extract/dnd/combat-turns/shape
|
||||
- extract/dnd/combat-turns/source_refs
|
||||
- extract/dnd/combat-turns/source_relatedness
|
||||
normalize:
|
||||
validators:
|
||||
- generic/valid_json
|
||||
- generic/valid_json_schema
|
||||
- extract/dnd/combat-turns/shape
|
||||
- normalize/dnd/combat-turns/invariants
|
||||
- extract/dnd/combat-turns/source_refs
|
||||
- extract/dnd/combat-turns/source_relatedness
|
||||
```
|
||||
|
||||
The production default chains for `dnd/npc-interactions` are:
|
||||
|
||||
```yaml
|
||||
extract:
|
||||
validators:
|
||||
- generic/valid_json
|
||||
- extract/dnd/npc-interactions/shape
|
||||
- extract/dnd/npc-interactions/registry
|
||||
- extract/dnd/npc-interactions/source_refs
|
||||
- generic/valid_json_schema
|
||||
- extract/dnd/npc-interactions/source_relatedness
|
||||
normalize:
|
||||
validators:
|
||||
- generic/valid_json
|
||||
- extract/dnd/npc-interactions/shape
|
||||
- extract/dnd/npc-interactions/registry
|
||||
- normalize/dnd/npc-interactions/invariants
|
||||
- extract/dnd/npc-interactions/source_refs
|
||||
- generic/valid_json_schema
|
||||
- extract/dnd/npc-interactions/source_relatedness
|
||||
```
|
||||
|
||||
The production default chains for `dnd/scene-descriptions` are:
|
||||
|
||||
```yaml
|
||||
extract:
|
||||
validators:
|
||||
- generic/valid_json
|
||||
- extract/dnd/scene-descriptions/shape
|
||||
- extract/dnd/scene-descriptions/source_refs
|
||||
- generic/valid_json_schema
|
||||
- extract/dnd/scene-descriptions/source_relatedness
|
||||
normalize:
|
||||
validators:
|
||||
- generic/valid_json
|
||||
- extract/dnd/scene-descriptions/shape
|
||||
- normalize/dnd/scene-descriptions/invariants
|
||||
- extract/dnd/scene-descriptions/source_refs
|
||||
- generic/valid_json_schema
|
||||
- extract/dnd/scene-descriptions/source_relatedness
|
||||
```
|
||||
|
||||
Empty chains approve output by default.
|
||||
|
||||
The `generic` chunker accepts:
|
||||
|
||||
@@ -178,36 +519,239 @@ The `generic` chunker accepts:
|
||||
- `overlap_units`: non-negative integer, default `0`, and must be less than
|
||||
`max_units`.
|
||||
|
||||
## Diagnostics
|
||||
The `dnd/scenes` chunker requires transcript source capabilities, calls the
|
||||
configured structured LLM runtime, and does not accept module options. It
|
||||
declares optional `players`, `party`, and `glossary` references for scene
|
||||
disambiguation, and accepts `roster` as a deprecated compatibility alias for
|
||||
`party`.
|
||||
|
||||
`diagnostics` fields:
|
||||
The `dnd/spells` extractor declares optional campaign reference slots:
|
||||
|
||||
- `work_dir`: directory for per-run diagnostics. Default: `/tmp/notarius`.
|
||||
- `retention`: `auto`, `always`, or `never`. Empty uses `auto`.
|
||||
- `players`
|
||||
- `party`
|
||||
- `glossary`
|
||||
- `roster` as a deprecated compatibility alias for `party`
|
||||
|
||||
`auto` retains diagnostics for failed runs and successful runs with warnings.
|
||||
`always` retains diagnostics for every run. `never` removes diagnostics for
|
||||
successful runs without regard to warnings; failed runs are retained.
|
||||
These campaign slots accept UTF-8 plain text, Markdown, YAML, or JSON reference
|
||||
files. The extractor also declares an optional `spell_catalog` slot that accepts
|
||||
one UTF-8 `application/json` overlay bundle no larger than 1 MiB. The slot does
|
||||
not allow multiple files. Its format is defined in the
|
||||
[spell-catalog overlay contract](integrations/dnd-spell-catalog-overlays.md).
|
||||
The extractor uses campaign references only as supporting disambiguation
|
||||
material; spell casts still must be present in the source transcript.
|
||||
|
||||
The `--diagnostics-dir` run flag overrides `diagnostics.work_dir` for that
|
||||
invocation.
|
||||
It also declares an optional `npcs` slot for a normalized NPC artifact. The
|
||||
slot accepts exactly one `application/json` artifact no larger than 1 MiB. An
|
||||
external file is decoded and identity-validated during preparation. A
|
||||
generated binding is validated at the step handoff and is provided to the
|
||||
operation through the same reference contract. In both cases, the model
|
||||
receives a names-only JSON projection for caster-name grounding. Registry source references
|
||||
may belong to the NPC-producing session and are provenance only; they are not
|
||||
spell evidence. Generated reference identity and bounded producer provenance
|
||||
are recorded by the framework; NPC names, content, and paths are not copied
|
||||
into manifests. Consumer-local checkpoint identity uses the names-only
|
||||
projection digest. When absent, the prompt receives the exact empty value
|
||||
`{"npcs":[]}` with its projection digest and no registry provenance.
|
||||
|
||||
The `dnd/spells` normalizer declares the same optional `spell_catalog` slot.
|
||||
When an overlay is used, bind it independently under
|
||||
`artifacts.<lane>.normalize.references.spell_catalog`; normalize-stage
|
||||
references are local to that stage and are not inherited from extraction. The
|
||||
normalizer uses the embedded SRD catalog when no normalize-stage overlay is
|
||||
bound.
|
||||
|
||||
The `dnd/npcs` extractor declares the same optional campaign slots as the spell
|
||||
extractor, but it does not declare the `npcs` registry slot. Its normalizer
|
||||
accepts no references. The
|
||||
[complete D&D example](../examples/dnd-complete.config.yml) binds its accepted
|
||||
normalized output to later spell, combat-turn, and NPC-interaction targets
|
||||
through an explicit ordered step.
|
||||
|
||||
The `dnd/scene-descriptions` extractor accepts only the optional `players`,
|
||||
`party`, and `glossary` campaign slots. They can disambiguate terms in the
|
||||
model-facing title and summary but are never source evidence. Its normalizer
|
||||
accepts no references. No NPC registry or generated artifact is required. See
|
||||
the [D&D scene-description artifact contract](integrations/dnd-scene-description-artifacts.md)
|
||||
and the copyable [complete D&D configuration](../examples/dnd-complete.config.yml).
|
||||
|
||||
The `dnd/combat-turns` extractor declares the optional campaign slots and the
|
||||
structured `npcs` slot. Campaign references guide only the LLM extraction
|
||||
stage. The deterministic normalizer declares only `npcs`, whose operation-time
|
||||
registry supports the same actor canonicalization. Each `npcs` slot
|
||||
accepts exactly one UTF-8 `application/json` artifact no larger than 1 MiB. The
|
||||
registry's source ranges remain provenance for the reference and never become
|
||||
combat evidence. An ordered step binding fans the same generated NPC artifact
|
||||
out to extraction and normalization:
|
||||
|
||||
```yaml
|
||||
references:
|
||||
npcs:
|
||||
artifact:
|
||||
step: identify-npcs
|
||||
lane: npcs
|
||||
```
|
||||
|
||||
When bound, the combat extractor and normalizer receive the generated registry
|
||||
at operation time. Framework provenance and checkpoint dependencies contain its
|
||||
kind, schema identity, media type, canonical digest, size, and bounded producer
|
||||
identity; names, content, and paths are not recorded there. When
|
||||
absent, the combat prompt receives the exact empty registry value
|
||||
`{"npcs":[]}` with its projection digest and no registry provenance.
|
||||
|
||||
Both `dnd/npc-interactions` stages require the structured `npcs` slot. Bind one
|
||||
accepted normalized NPC artifact from an earlier ordered step; the interaction
|
||||
extractor receives only its names-only projection, while the normalizer uses
|
||||
the immutable registry for exact canonical-name lookup. A missing, rejected,
|
||||
or incompatible producer prevents the interaction step from running. Registry
|
||||
source references remain identity provenance and never supply interaction
|
||||
evidence: every interaction must cite current transcript units. The complete
|
||||
durable contract and two-step configuration are defined in the
|
||||
[D&D NPC interaction artifact contract](integrations/dnd-npc-interaction-artifacts.md).
|
||||
|
||||
## State Surfaces
|
||||
|
||||
The `output`, `cache`, and `debug` top-level fields select independent physical
|
||||
roots. Their layout, permissions, lifecycle, and sensitive-data handling are
|
||||
defined in [Operations](operations.md).
|
||||
|
||||
```yaml
|
||||
output:
|
||||
directory: ./notarius-output
|
||||
cache:
|
||||
chunk_plans:
|
||||
directory: ""
|
||||
mode: auto
|
||||
checkpoints:
|
||||
enabled: false
|
||||
directory: ""
|
||||
debug:
|
||||
directory: ./notarius-debug
|
||||
```
|
||||
|
||||
`output.directory` is the durable output root. Its precedence is
|
||||
`--output-dir`, `NOTARIUS_OUTPUT_DIR`, the file value, then the default.
|
||||
|
||||
`cache.chunk_plans.mode` accepts `auto`, `bypass`, or `refresh`. Its precedence
|
||||
is `--chunk_cache`, `NOTARIUS_CACHE_CHUNK_PLANS_MODE`, the file value, then
|
||||
`auto`. `auto` reuses a valid source-addressed plan and regenerates missing or
|
||||
invalid records; `bypass` performs no plan-cache I/O; `refresh` regenerates and
|
||||
publishes a plan after chunk validation.
|
||||
|
||||
`cache.chunk_plans.directory` and `cache.checkpoints.directory` each name an
|
||||
exact cache-family root. Their precedence is the corresponding environment
|
||||
variable, the file value, then the family-specific per-user default. There is
|
||||
no CLI cache-root override. The defaults are
|
||||
`<os.UserCacheDir>/notarius/chunk-plans` and
|
||||
`<os.UserCacheDir>/notarius/checkpoints`; on Unix, `os.UserCacheDir` ordinarily
|
||||
uses an absolute `$XDG_CACHE_HOME` or falls back to `$HOME/.cache`. A relative
|
||||
`XDG_CACHE_HOME` is an error.
|
||||
|
||||
`cache.checkpoints.enabled` defaults to `false`. When `true`, every run records
|
||||
checkpoint transitions and reusable approved results. When `false`, Notarius
|
||||
does not resolve or create the checkpoint root, and `--resume` is rejected.
|
||||
The `--resume` flag authorizes loading compatible checkpoints; it does not
|
||||
control recording.
|
||||
|
||||
`debug.directory` chooses a root but never enables debug capture. Its precedence
|
||||
is `--debug-dir`, `NOTARIUS_DEBUG_DIR`, the file value, then the default.
|
||||
Only `--debug` requests a bundle; `--debug-dir` is valid only with `--debug`.
|
||||
|
||||
Every supplied file, environment, and CLI value is validated even when a
|
||||
higher-precedence value wins.
|
||||
|
||||
## Version 2 To Version 3 Migration
|
||||
|
||||
Version 2 files are rejected. Move each setting to the surface it controls and
|
||||
remove obsolete enablement and retention controls. This complete before/after
|
||||
example preserves an existing chunk-plan cache and checkpoint directory while
|
||||
choosing an output and debug root explicitly.
|
||||
|
||||
```yaml
|
||||
# Version 2 (no longer accepted)
|
||||
version: 2
|
||||
workspace:
|
||||
directory: /srv/notarius/state
|
||||
resume:
|
||||
enabled: true
|
||||
debug:
|
||||
enabled: true
|
||||
chunk_cache:
|
||||
directory: /srv/notarius/chunk-plans
|
||||
mode: auto
|
||||
diagnostics:
|
||||
retention: always
|
||||
diagnostics:
|
||||
work_dir: /srv/notarius/inspection
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Version 3
|
||||
version: 3
|
||||
output:
|
||||
directory: /srv/notarius/output
|
||||
cache:
|
||||
chunk_plans:
|
||||
directory: /srv/notarius/chunk-plans
|
||||
mode: auto
|
||||
checkpoints:
|
||||
enabled: true
|
||||
directory: /srv/notarius/state/checkpoints
|
||||
debug:
|
||||
directory: /srv/notarius/debug
|
||||
```
|
||||
|
||||
Run the migrated configuration with `--resume` when checkpoint reuse is wanted,
|
||||
and with `--debug` when a debug bundle is wanted. Enabled checkpoint recording
|
||||
occurs with or without `--resume`.
|
||||
|
||||
The removed fields are `workspace.directory`, `workspace.resume.enabled`,
|
||||
`workspace.debug.enabled`, `workspace.chunk_cache.mode`,
|
||||
`workspace.chunk_cache.directory`, `workspace.diagnostics.enabled`,
|
||||
`workspace.diagnostics.retention`, `diagnostics.work_dir`, and
|
||||
`diagnostics.retention`. The removed environment variables are `NOTARIUS_WORKSPACE_DIR`,
|
||||
`NOTARIUS_WORKSPACE_DIAGNOSTICS_ENABLED`,
|
||||
`NOTARIUS_WORKSPACE_DIAGNOSTICS_RETENTION`,
|
||||
`NOTARIUS_WORKSPACE_RESUME_ENABLED`, `NOTARIUS_WORKSPACE_DEBUG_ENABLED`,
|
||||
`NOTARIUS_WORKSPACE_CHUNK_CACHE_MODE`,
|
||||
`NOTARIUS_WORKSPACE_CHUNK_CACHE_DIR`, `NOTARIUS_WORK_DIR`, and
|
||||
`NOTARIUS_DIAGNOSTICS_RETENTION`. The chunk-cache variables are replaced by
|
||||
`NOTARIUS_CACHE_CHUNK_PLANS_MODE` and
|
||||
`NOTARIUS_CACHE_CHUNK_PLANS_DIR`; the former shared directory has no direct
|
||||
replacement.
|
||||
|
||||
## Validation
|
||||
|
||||
Configuration validation checks:
|
||||
|
||||
- supported config version and known YAML fields;
|
||||
- mutually exclusive `scriptorium.profile_dir` and `scriptorium.profile_file`;
|
||||
- non-empty, non-duplicated IDs after trimming;
|
||||
- supported LLM provider and non-negative profile limits;
|
||||
- positive global LLM concurrency;
|
||||
- supported diagnostics retention and non-empty work directory;
|
||||
- module binding LLM profiles refer to configured profiles.
|
||||
- supported stage-worker keys and an effective extract worker count in the
|
||||
inclusive range `1..concurrency.total_llm`;
|
||||
- non-empty output and debug directories;
|
||||
- a supported chunk-cache mode, Boolean checkpoint enablement, and state-surface
|
||||
directories without NUL bytes;
|
||||
- stale removed fields such as `llm_profiles`.
|
||||
|
||||
Pipeline resolution additionally checks:
|
||||
|
||||
- the pipeline ID exists;
|
||||
- at least one artifact lane is declared and selected;
|
||||
- selected lanes exist when `--only` is used;
|
||||
- at least one artifact lane is declared and selected in each explicit step;
|
||||
- `artifacts` and `steps` are mutually exclusive, explicit steps are non-empty,
|
||||
and step IDs are unique after trimming;
|
||||
- lanes selected through the CLI exist in the resolved pipeline;
|
||||
- lane IDs are globally unique across ordered steps;
|
||||
- required module keys are present;
|
||||
- module keys are registered for the expected slot;
|
||||
- module capability requirements are satisfied.
|
||||
- module capability requirements are satisfied;
|
||||
- non-empty validator overrides reference registered validator keys;
|
||||
- deterministic validators do not set `llm_profile`;
|
||||
- LLM-backed validators with explicit `llm_profile` values reference configured
|
||||
Scriptorium profile IDs;
|
||||
- bound reference slots are declared by selected chunk, extractor, merger, or
|
||||
normalizer targets;
|
||||
- generated references identify one lane in an earlier step, use a declared
|
||||
compatible artifact kind, and do not conflict with external or target-local
|
||||
generated bindings;
|
||||
- required reference slots are bound for selected targets.
|
||||
|
||||
41
docs/development.md
Normal file
41
docs/development.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Development
|
||||
|
||||
This is the first-read landing page for people and LLM coding agents working on
|
||||
Notarius. It provides a concise repository orientation and routes each kind of
|
||||
change to its canonical documentation.
|
||||
|
||||
Notarius is a Go CLI for configured structured extraction workflows. Start with
|
||||
the [README](../README.md) for product context, [Architecture](policy/architecture.md)
|
||||
for system boundaries, and [Internal Overview](internal/overview.md) for the
|
||||
implemented component map.
|
||||
|
||||
## What To Read
|
||||
|
||||
| When working on | Read | Why |
|
||||
| --- | --- | --- |
|
||||
| Finding the package or component that owns current behavior | [Internal Overview](internal/overview.md) | It is the implemented component inventory and routes to focused internals. |
|
||||
| Application shape, package boundaries, contracts, dependency direction, runtime guarantees, or safety properties | [Architecture](policy/architecture.md) and relevant [ADRs](adr/) | Architecture defines the intended system and its invariants; ADRs preserve significant decision rationale. |
|
||||
| Any documentation addition or revision | [Documentation Policy](policy/documentation.md) | It defines canonical homes, audiences, current-behavior rules, and maintenance requirements. |
|
||||
| Adding, changing, reviewing, or deleting tests | [Testing Policy](policy/testing.md) | It defines risk-based sufficiency, durable test boundaries, test-double guidance, and criteria for retaining tests. |
|
||||
| Pipeline resolution or execution | [Pipeline Internals](internal/pipeline.md) | It documents profiles, references, validation, retries, checkpoints, and runner behavior. |
|
||||
| Production modules or validators | [Module Internals](internal/modules.md) | It documents implemented module contracts, capabilities, assets, and registration. |
|
||||
| LLM clients, prompts, schemas, profiles, or scheduling | [LLM Runtime](internal/llm.md) | It documents the transport boundary and Scriptorium integration. |
|
||||
| Output, cache, resume, or debug artifacts | [Run State Internals](internal/state.md), [Operations](operations.md), and [Configuration](config.md) | These separate implementation details, operator behavior, and configuration contracts. |
|
||||
| CLI or user-visible configuration behavior | [CLI Reference](cli.md) and [Configuration](config.md) | These are the canonical user and operator references. |
|
||||
| External input formats, artifact schemas, or durable output files | [Integration Contracts](integrations/) | Integration documents define external and durable data contracts. |
|
||||
| Proposed or unimplemented behavior | [Roadmap](roadmap/) | Future work belongs only in roadmap documentation until implemented. |
|
||||
|
||||
For an existing subsystem, also inspect its focused tests and the package-local
|
||||
types and contracts before changing behavior.
|
||||
|
||||
## Validation
|
||||
|
||||
Use focused package tests while iterating. Run the repository-wide checks when
|
||||
a change affects shared contracts, application behavior, or maintained
|
||||
documentation examples:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./cmd/notarius
|
||||
```
|
||||
83
docs/integrations/chunk-map.md
Normal file
83
docs/integrations/chunk-map.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Accepted Chunk Map
|
||||
|
||||
This document defines the durable accepted chunk-map artifact that the JSON
|
||||
output encoder can write as `chunk-map.json`. It describes the exact accepted,
|
||||
materialized chunks used by a run; it is not a lane artifact and is never an
|
||||
input to later pipeline steps. Enable it with the JSON output option described
|
||||
in [Configuration](../config.md#json-output-options).
|
||||
|
||||
## Identity
|
||||
|
||||
- Artifact kind: `source/chunk-map`
|
||||
- Logical file: `chunk-map.json`
|
||||
- Schema ID: `notarius.source.chunk_map`
|
||||
- Schema name: `notarius_source_chunk_map_v1`
|
||||
- Schema version: `v1`
|
||||
- Media type: `application/json`
|
||||
|
||||
The checked-in [JSON Schema](../../internal/framework/chunkmap/assets/schemas/source_chunk_map.v1.json)
|
||||
defines the strict wire shape. `chunk-map.json` is listed by the optional
|
||||
`chunk_map` descriptor in [the JSON output index](json-output.md#indexjson),
|
||||
not by the lane-oriented `output_files` collection.
|
||||
|
||||
## Payload
|
||||
|
||||
The payload has these required fields:
|
||||
|
||||
- `source_id`: accepted source-document identity.
|
||||
- `source_digest`: canonical lower-case `sha256:` digest of that document.
|
||||
- `plan_digest`: canonical lower-case `sha256:` digest of the accepted logical
|
||||
plan.
|
||||
- `requested_chunker`: chunk module selected by the current resolved pipeline.
|
||||
- `producer`: the original accepted-plan producer, with required
|
||||
`input_module` and `chunk_module`; `llm_profile` is present only for an
|
||||
LLM-backed producer.
|
||||
- `plan_annotations`: accepted plan-level annotation namespace map. It is
|
||||
`{}` when no namespaces are present.
|
||||
- `chunks`: non-empty execution-order collection of accepted chunks.
|
||||
|
||||
Each chunk has `id`, zero-based `index`, `source_ref`, positive `unit_count`,
|
||||
and an explicit `annotations` namespace map. A source reference has the source
|
||||
ID and inclusive positive `start_unit_id` and `end_unit_id` endpoints.
|
||||
Annotation values are arbitrary valid JSON under non-empty namespaces. They
|
||||
are preserved as canonical JSON without interpreting any module-specific
|
||||
namespace.
|
||||
|
||||
## Invariants
|
||||
|
||||
The framework constructs this artifact only after materializing the selected
|
||||
logical plan and accepting it through the configured chunk validator chain.
|
||||
Construction proves the source and plan digests, source-document range order,
|
||||
materialized chunk IDs and indexes, source references, unit membership and
|
||||
counts, and plan/range annotations agree exactly. Chunk IDs are unique and
|
||||
indexes are contiguous and agree with array order.
|
||||
|
||||
The codec rejects unknown fixed-object fields, malformed identities or
|
||||
digests, invalid annotation JSON, trailing JSON content, and any payload whose
|
||||
reconstructed logical plan does not reproduce `plan_digest`. It makes
|
||||
defensive copies at serialization and decoding boundaries.
|
||||
|
||||
## Acceptance And Provenance
|
||||
|
||||
The artifact is available only when the chunk plan was accepted. It remains
|
||||
available when a later extraction, merge, or normalization result is rejected;
|
||||
it is absent when chunk validation rejects the candidate plan.
|
||||
|
||||
`requested_chunker` describes the current pipeline selection. `producer`
|
||||
describes who originally produced the accepted plan. On a cache hit these can
|
||||
differ: the accepted ranges, annotations, digests, and stable materialized IDs
|
||||
are reused, while the producer remains the stored producer. Cache paths,
|
||||
actions, references, metadata, warnings, timestamps, and detailed provenance
|
||||
remain in the run manifest rather than this payload.
|
||||
|
||||
## Data Handling
|
||||
|
||||
The map contains structure, not source content. It excludes transcript bytes,
|
||||
materialized units, source-unit metadata, chunk content, private model
|
||||
responses, rejected proposals, debug data, external-reference content, and
|
||||
filesystem paths.
|
||||
|
||||
Annotations can nevertheless be source- or model-derived. Treat an enabled
|
||||
`chunk-map.json` with the same sensitivity and retention expectations as lane
|
||||
output. Physical placement, confined atomic writing, and permissions follow
|
||||
the ordinary [output operation](../operations.md#output).
|
||||
163
docs/integrations/dnd-combat-turn-artifacts.md
Normal file
163
docs/integrations/dnd-combat-turn-artifacts.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# D&D Combat-Turn Artifact Contract
|
||||
|
||||
This document defines the durable artifact, serialization, extraction,
|
||||
candidate-validation, normalization, and production lane boundaries for D&D
|
||||
combat turns.
|
||||
|
||||
## Artifact identity
|
||||
|
||||
| Property | Value |
|
||||
| --- | --- |
|
||||
| Artifact kind | `dnd/combat-turn-list` |
|
||||
| Schema ID | `notarius.dnd.combat_turns` |
|
||||
| Schema name | `notarius_dnd_combat_turns_v1` |
|
||||
| Schema version | `v1` |
|
||||
| Media type | `application/json` |
|
||||
|
||||
The top-level JSON object contains the required `combat_turns` array, which
|
||||
may be empty. Every object rejects unknown fields.
|
||||
|
||||
## JSON shape
|
||||
|
||||
Each combat turn contains these required fields:
|
||||
|
||||
| Field | Shape |
|
||||
| --- | --- |
|
||||
| `actor` | Non-empty string. |
|
||||
| `turn_kind` | One of `turn`, `reaction`, `legendary_action`, `lair_action`, or `other`. |
|
||||
| `source_refs` | Required array with at least one source reference. |
|
||||
|
||||
Source references use the shared source-reference shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_id": "session-alpha",
|
||||
"start_unit_id": 1,
|
||||
"end_unit_id": 2
|
||||
}
|
||||
```
|
||||
|
||||
`source_id` must be non-empty and both unit IDs must be positive integers. The
|
||||
codec does not resolve references against a source document or enforce source
|
||||
range ordering; those checks belong to the later source-reference validation
|
||||
boundary.
|
||||
|
||||
## Codec behavior
|
||||
|
||||
The codec exposes two representations of the same typed artifact:
|
||||
|
||||
- Candidate encode/decode preserves invalid actor and turn-kind values,
|
||||
collection presence, and source references so later validators can report
|
||||
them. Candidate decoding still requires valid JSON, one JSON value, known
|
||||
fields, and compatible JSON types.
|
||||
- Approved encode/decode enforces the structural rules in this contract.
|
||||
|
||||
The codec owns the durable JSON Schema, whose object layers all set
|
||||
`additionalProperties` to `false`. Codec metadata contains only
|
||||
`combat_turn_count`.
|
||||
|
||||
The maintained compact fixture is
|
||||
`internal/modules/dnd/codec/combatturns/testdata/dnd_combat_turns.v1.json`.
|
||||
|
||||
## Extraction boundary
|
||||
|
||||
The standalone extractor uses these identities:
|
||||
|
||||
| Property | Value |
|
||||
| --- | --- |
|
||||
| Extractor key | `dnd/combat-turns` |
|
||||
| Capability | `dnd.combat_turns` |
|
||||
| Prompt ID | `dnd.combat_turns` |
|
||||
| Prompt version | `v1` |
|
||||
| Private response-schema key | `dnd_combat_turns_llm` |
|
||||
| Private response-schema ID | `notarius.dnd.combat_turns.llm` |
|
||||
| Default profile | `gemini-2-flash` |
|
||||
|
||||
It requires `chunks` and `source.transcript`, accepts no options, and makes one
|
||||
structured completion for each supplied chunk. The prompt receives the
|
||||
chunk-scoped transcript plus the existing `players`, `party`, and `glossary`
|
||||
inputs, and optionally the deprecated `roster` reference through the shared
|
||||
party mapping. The optional `npcs` reference is an approved normalized NPC
|
||||
artifact used only for identity grounding; it never supplies combat evidence.
|
||||
An external file is validated during preparation. In an ordered pipeline, the
|
||||
same slot may receive the producer's canonical generated artifact at the step
|
||||
handoff.
|
||||
|
||||
The private response envelope has the same turn fields and JSON types as the
|
||||
durable shape except that source references contain only `start_unit_id`
|
||||
and `end_unit_id`. It enforces required field presence, types, and
|
||||
unknown-field rejection, while deterministic validators own enum membership,
|
||||
non-empty values and collections, and positive-number requirements. The
|
||||
extractor assigns the current source ID, removes exact duplicate ranges, and
|
||||
stable-sorts turns by the earliest valid source-document position. Numeric unit
|
||||
IDs are identifiers; source-document slice position determines chronology.
|
||||
Semantically malformed candidate fields remain in the typed result for the
|
||||
configured validation and retry boundary.
|
||||
|
||||
## Deterministic candidate validation
|
||||
|
||||
The standalone validator keys are:
|
||||
|
||||
| Validator | Responsibility |
|
||||
| --- | --- |
|
||||
| `extract/dnd/combat-turns/shape` | Required list, actor, turn kind, and source references, plus supported turn-kind values. |
|
||||
| `extract/dnd/combat-turns/source_refs` | Source identity, source-unit existence, and range order through the source document. |
|
||||
| `extract/dnd/combat-turns/source_relatedness` | At most one advisory warning per turn when the actor is not related to cited transcript text. |
|
||||
|
||||
Source-reference and relatedness validators defer malformed shape to the shape
|
||||
validator. Relatedness also defers when any cited source range is invalid. It
|
||||
combines overlapping cited ranges once in document order and compares actors
|
||||
with the shared Unicode-aware NPC identity policy.
|
||||
|
||||
The production D&D registrar exposes the extractor and these validators. Its
|
||||
default extraction chain preserves this order: JSON syntax, combat shape,
|
||||
source references, private response schema, then source relatedness.
|
||||
|
||||
## Normalization boundary
|
||||
|
||||
The standalone normalizer uses key `dnd/combat-turns`, requires `merged`,
|
||||
provides `normalized`, accepts no options, and accepts only the optional
|
||||
structured `npcs` reference. Campaign references are LLM extraction context and
|
||||
are not normalizer inputs. For an external file, the NPC registry is resolved
|
||||
during preparation; for a generated binding, it is resolved at the operation-
|
||||
time handoff. Runtime normalization uses that immutable prepared or handed-off
|
||||
view.
|
||||
|
||||
Normalization policy is `dnd.combat_turns.normalize.v1`. It display-normalizes
|
||||
the actor, canonicalizes exact registry actor matches, orders and deduplicates
|
||||
exact source references, stable-sorts records by earliest valid source-document
|
||||
position, and collapses only records with the same actor identity, turn kind,
|
||||
and complete valid evidence set. The first normalized record is retained.
|
||||
Invalid evidence is never eligible for duplicate collapse. Every mutation and
|
||||
collapse emits a bounded warning using the merged input index in its scope.
|
||||
|
||||
The normalizer reports `normalization_policy` and `identity_policy` metadata
|
||||
and fingerprints. An external registry may additionally contribute
|
||||
`npc_registry_digest` and `npc_count`; generated registry identity is retained
|
||||
in framework handoff provenance and dependency fingerprints. The
|
||||
normalized-invariants validator is
|
||||
`normalize/dnd/combat-turns/invariants`; it defers shape and source-reference
|
||||
failures, then checks actor display normalization, canonical evidence ordering,
|
||||
chronology, and duplicate identity. It rejects
|
||||
with `invalid_combat_turn_normalization` under policy
|
||||
`dnd.combat_turns.validator.normalized.v1`.
|
||||
|
||||
The production D&D registrar exposes the normalizer and normalized-invariants
|
||||
validator. Its default normalization chain is JSON syntax, combat shape,
|
||||
normalized invariants, source references, durable schema, then source
|
||||
relatedness. The lane uses the framework's typed append-order merger and has no
|
||||
merge validator chain.
|
||||
|
||||
## Production manifest and references
|
||||
|
||||
The selectable lane uses extractor and normalizer key `dnd/combat-turns`,
|
||||
`appendorder` for the typed merger, and the durable codec above. A bound `npcs`
|
||||
reference contributes raw-file provenance to the run manifest. A generated
|
||||
binding contributes artifact kind, schema identity, media type, canonical
|
||||
digest, size, and bounded producer provenance. Consumer metadata and checkpoint
|
||||
fingerprints contain no registry names, content, paths, or NPC source ranges.
|
||||
The component-local registry fingerprint covers only the names projected to the
|
||||
consumer, while manifest provenance retains the full artifact digest. The
|
||||
normalized lane is emitted as `lanes/<lane-id>.json` by the JSON
|
||||
output module, and warnings and rejection summaries remain in their shared
|
||||
companion files.
|
||||
143
docs/integrations/dnd-npc-artifacts.md
Normal file
143
docs/integrations/dnd-npc-artifacts.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# D&D NPC Artifact
|
||||
|
||||
This document defines the durable D&D NPC-list artifact, its JSON codec, and
|
||||
the selectable production NPC pipeline. The normalized JSON payload can be
|
||||
passed explicitly to the spell extractor as an optional caster-name registry
|
||||
or to the combat extractor and normalizer as an actor registry. It
|
||||
remains a reference, not spell or combat evidence.
|
||||
The NPC interaction extractor and normalizer also consume this registry for
|
||||
canonical identity; registry source references remain provenance and never
|
||||
become interaction evidence. Their occurrence contract is defined in the
|
||||
[D&D NPC interaction artifact](dnd-npc-interaction-artifacts.md).
|
||||
|
||||
## Identity
|
||||
|
||||
- Artifact kind: `dnd/npc-list`
|
||||
- Durable schema ID: `notarius.dnd.npcs`
|
||||
- Durable schema name: `notarius_dnd_npcs_v1`
|
||||
- Durable schema version: `v1`
|
||||
- Media type: `application/json`
|
||||
- Identity policy: `dnd.npcs.identity.v1`
|
||||
|
||||
The durable JSON Schema is owned by the D&D NPC codec. NPC IDs are derived from
|
||||
the Unicode-normalized, case-folded canonical name using the identity policy.
|
||||
The durable codec enforces the artifact shape and ID syntax; registry identity
|
||||
validation remains a separate deterministic concern.
|
||||
|
||||
The extractor's private LLM response schema is a separate structural transport
|
||||
contract. It omits framework-assigned NPC and source IDs and admits semantic
|
||||
candidates for the deterministic shape and source-reference validators; it is
|
||||
not part of this durable contract.
|
||||
|
||||
## Output Shape
|
||||
|
||||
The payload is one object with a required top-level `npcs` array:
|
||||
|
||||
```json
|
||||
{"npcs": []}
|
||||
```
|
||||
|
||||
The array may be empty. Every object and nested object rejects unknown fields.
|
||||
|
||||
## NPC Fields
|
||||
|
||||
Each NPC contains exactly these required fields:
|
||||
|
||||
- `id`: `npc:sha256:` followed by 64 lowercase hexadecimal characters;
|
||||
- `name`: the canonical display name;
|
||||
- `source_refs`: at least one source reference supporting the NPC record.
|
||||
|
||||
Each source reference contains required `source_id`, `start_unit_id`, and
|
||||
`end_unit_id`; unit IDs are positive integers. Source document identity, unit
|
||||
existence, and range ordering are validated by the source-reference validator
|
||||
when the artifact is used by a pipeline.
|
||||
|
||||
## Codec Boundary
|
||||
|
||||
`EncodeCandidate` and `DecodeCandidate` provide strict single-value JSON
|
||||
serialization while preserving typed values that still need semantic
|
||||
validation. `Encode` and `Decode` are the approved-artifact boundary and
|
||||
require all durable structural fields, non-empty required strings, valid source
|
||||
reference shapes, and the NPC ID pattern.
|
||||
|
||||
Codec metadata contains only `npc_count`. Schema bytes and returned metadata
|
||||
are independent values so callers cannot mutate codec-owned state.
|
||||
|
||||
## Production Pipeline
|
||||
|
||||
The production identities are:
|
||||
|
||||
- extractor: `dnd/npcs`;
|
||||
- artifact kind: `dnd/npc-list`;
|
||||
- normalizer: `dnd/npcs`; and
|
||||
- durable schema: `notarius.dnd.npcs`, version `v1`, media type
|
||||
`application/json`.
|
||||
|
||||
The extractor maps private model records to the current source identity and
|
||||
assigns deterministic IDs. Extraction validation checks shape, source
|
||||
references, and source relatedness. The normalizer then consolidates records
|
||||
only when their normalized canonical names match, preserves the first record's
|
||||
display and output position, unions exact evidence, and validates the retained
|
||||
registry's identity. No LLM is used for consolidation.
|
||||
|
||||
The extraction prompt asks only for individually identifiable NPC names backed
|
||||
by source evidence. Groups, generic roles, invented labels, and descriptive or
|
||||
relationship enrichment are outside the contract.
|
||||
|
||||
The default extraction chain is `generic/valid_json`,
|
||||
`extract/dnd/npcs/shape`, `extract/dnd/npcs/source_refs`,
|
||||
`generic/valid_json_schema`, and `extract/dnd/npcs/source_relatedness`. The
|
||||
default normalize chain is `generic/valid_json`, `extract/dnd/npcs/shape`,
|
||||
`normalize/dnd/npcs/identity`, `extract/dnd/npcs/source_refs`,
|
||||
`generic/valid_json_schema`, and `extract/dnd/npcs/source_relatedness`.
|
||||
Relatedness emits bounded warnings when an NPC canonical name is not present
|
||||
near its cited transcript text; opaque campaign
|
||||
references may explain such a warning but do not become evidence.
|
||||
|
||||
## Manifest And Artifact Handoff
|
||||
|
||||
The NPC extractor records prompt and response-schema identities. The durable
|
||||
codec records only `npc_count`; raw names, source references, and payload bytes
|
||||
stay in the lane file rather than manifest
|
||||
metadata. The normalized lane can be consumed by a later ordered step through
|
||||
the registered canonical codec:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- id: identify-npcs
|
||||
artifacts:
|
||||
npcs:
|
||||
extract: dnd/npcs
|
||||
normalize: dnd/npcs
|
||||
- id: grounded-events
|
||||
references:
|
||||
npcs:
|
||||
artifact:
|
||||
step: identify-npcs
|
||||
lane: npcs
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
normalize: dnd/spells
|
||||
combat:
|
||||
extract: dnd/combat-turns
|
||||
normalize: dnd/combat-turns
|
||||
```
|
||||
|
||||
The framework hands only an accepted normalized artifact across the barrier. It
|
||||
validates the canonical bytes against each consumer slot and clones the
|
||||
operation-time reference for the spell and combat consumers. Generated
|
||||
provenance records the artifact kind, schema identity, media type, canonical
|
||||
digest, size, and producer step/lane/module, but not names, source
|
||||
ranges, or payload bytes. External normalized files remain supported as
|
||||
explicit references and retain their file provenance.
|
||||
|
||||
NPC source references are registry provenance and are never accepted as spell
|
||||
or combat evidence. Current transcript units remain the only event evidence.
|
||||
|
||||
Consumers receive a separate names-only projection in normalized registry
|
||||
order, for example `{"npcs":[{"name":"Mira Thorn"}]}`. The projection omits
|
||||
IDs and evidence. Its digest covers the exact projected bytes and is used for
|
||||
consumer-local checkpoint identity, while the full durable artifact digest
|
||||
remains the manifest and generated-reference provenance identity. The unbound
|
||||
projection is exactly `{"npcs":[]}` and also has a projection digest.
|
||||
148
docs/integrations/dnd-npc-interaction-artifacts.md
Normal file
148
docs/integrations/dnd-npc-interaction-artifacts.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# D&D NPC Interaction Artifact
|
||||
|
||||
This document defines the durable D&D NPC-interaction-list artifact and its
|
||||
two-step production pipeline. It records discrete, source-grounded occurrences
|
||||
for NPCs already accepted into a normalized NPC registry; it does not expand
|
||||
the registry or summarize events.
|
||||
|
||||
## Identity And JSON
|
||||
|
||||
- Artifact kind: `dnd/npc-interaction-list`
|
||||
- Durable schema ID: `notarius.dnd.npc_interactions`
|
||||
- Durable schema name: `notarius_dnd_npc_interactions_v1`
|
||||
- Durable schema version: `v1`
|
||||
- Media type: `application/json`
|
||||
|
||||
The payload is one strict JSON object with only an `interactions` array. The
|
||||
array may be empty. Each item has exactly `name`, `kind`, and `source_refs`:
|
||||
|
||||
```json
|
||||
{
|
||||
"interactions": [
|
||||
{
|
||||
"name": "Mira Thorn",
|
||||
"kind": "dialogue",
|
||||
"source_refs": [
|
||||
{"source_id": "session-7", "start_unit_id": 12, "end_unit_id": 13}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`name` is the canonical display name from the required NPC registry.
|
||||
`source_refs` contains one or more current-source ranges with required
|
||||
`source_id`, `start_unit_id`, and `end_unit_id`; unit IDs are positive integers.
|
||||
During extraction, every range must be wholly contained in the current accepted
|
||||
chunk. This prevents a candidate from citing valid units that were not presented
|
||||
to that extraction call.
|
||||
Unknown fields are rejected.
|
||||
|
||||
## Interaction Categories
|
||||
|
||||
`kind` is exactly one of:
|
||||
|
||||
| Kind | Meaning |
|
||||
| --- | --- |
|
||||
| `mentioned` | The NPC is referred to, but is not established as present or communicating in the evidenced passage. |
|
||||
| `noncombat_presence` | The NPC is present and relevant to the passage but does not meaningfully participate in dialogue or combat. |
|
||||
| `dialogue` | The NPC speaks, responds, or is directly engaged in a meaningful non-combat exchange. |
|
||||
| `combat_ally` | The NPC actively participates in combat on the party's side. |
|
||||
| `combat_opponent` | The NPC actively participates in combat against the party. |
|
||||
| `other` | The transcript clearly establishes a direct NPC occurrence that fits none of the preceding kinds. |
|
||||
|
||||
`other` is a residual category for positively evidenced activity, not a fallback
|
||||
for uncertain classification. When activities overlap, active combat
|
||||
participation outranks dialogue, presence, and mention; dialogue outranks
|
||||
non-combat presence and mention; and non-combat presence outranks mention.
|
||||
Combat alignment is not resolved by precedence: a meaningful change between
|
||||
ally and opponent creates separate occurrences.
|
||||
|
||||
These categories do not encode summaries, relationships, state, motives, or
|
||||
unobserved events.
|
||||
|
||||
## Occurrence Boundaries And Ordering
|
||||
|
||||
One occurrence represents one NPC, one kind, and one locally coherent passage
|
||||
within one accepted chunk. Repeated evidence belongs to the same occurrence
|
||||
only while it supports the same uninterrupted activity. A kind change, combat
|
||||
alignment change, intervening scene or meaningful absence, or transition from
|
||||
mention to presence starts a new occurrence. Occurrences never span chunks, and
|
||||
merge or normalization never semantically combines nearby, overlapping, or
|
||||
cross-chunk records.
|
||||
|
||||
Normalization orders records by:
|
||||
|
||||
1. earliest valid source-document position;
|
||||
2. the NPC identity comparison key;
|
||||
3. the exact canonical NPC display name;
|
||||
4. interaction kind in lexical order; and
|
||||
5. the complete canonical source-reference sequence, ordered by source ID and
|
||||
the source-document positions of each range's start and end.
|
||||
|
||||
Only records with identical canonical names, kinds, and complete valid evidence
|
||||
sequences are duplicates. Different categories, ranges, or separately grounded
|
||||
occurrences remain separate.
|
||||
|
||||
## Evidence, Registry, And Normalization
|
||||
|
||||
The registry proves only the canonical NPC identity. Its source references are
|
||||
registry provenance and are never interaction evidence. Every durable
|
||||
interaction must cite current transcript units supporting both the name and its
|
||||
classification.
|
||||
|
||||
The extractor receives a names-only registry projection such as
|
||||
`{"npcs":[{"name":"Mira Thorn"}]}`. The normalizer uses the full immutable
|
||||
registry for exact canonical-name lookup. It canonicalizes source references
|
||||
and applies the ordering and exact-duplicate rules above.
|
||||
|
||||
## Production Pipeline
|
||||
|
||||
The extractor and normalizer key is `dnd/npc-interactions`. Both require the
|
||||
structured `npcs` slot, so an accepted normalized registry must come from an
|
||||
earlier step:
|
||||
|
||||
```yaml
|
||||
pipelines:
|
||||
dnd-npc-interactions:
|
||||
input: seriatim
|
||||
steps:
|
||||
- id: identify-npcs
|
||||
artifacts:
|
||||
npcs:
|
||||
extract: dnd/npcs
|
||||
normalize: dnd/npcs
|
||||
- id: extract-interactions
|
||||
references:
|
||||
npcs:
|
||||
artifact:
|
||||
step: identify-npcs
|
||||
lane: npcs
|
||||
artifacts:
|
||||
interactions:
|
||||
extract: dnd/npc-interactions
|
||||
normalize: dnd/npc-interactions
|
||||
```
|
||||
|
||||
The framework passes only the accepted normalized producer. A missing, rejected,
|
||||
or incompatible NPC artifact prevents the consumer step from executing. It
|
||||
records generated artifact identity and bounded producer provenance without
|
||||
copying registry names, source ranges, or payload content into the manifest.
|
||||
|
||||
## Validation And Metadata
|
||||
|
||||
The default extract chain is `generic/valid_json`, interaction shape, registry,
|
||||
and source-reference validation, `generic/valid_json_schema`, then warning-only
|
||||
source relatedness. The normalize chain runs normalized invariants after
|
||||
registry validation and before source-reference and schema validation, followed
|
||||
by relatedness. Normalizer and relatedness warnings are bounded and end with an
|
||||
omission summary when necessary. The codec metadata contains only
|
||||
`interaction_count`. Extractor metadata identifies its prompt and private
|
||||
response schema; component-local checkpoint identities include the names-only
|
||||
registry projection where relevant. Generated registry identity stays in
|
||||
framework provenance and dependency fingerprints.
|
||||
|
||||
See [Configuration](../config.md#implemented-production-modules) for selectable
|
||||
keys and chains, [the NPC artifact contract](dnd-npc-artifacts.md) for the
|
||||
registry boundary, and the copyable
|
||||
[complete D&D example](../../examples/dnd-complete.config.yml).
|
||||
120
docs/integrations/dnd-scene-description-artifacts.md
Normal file
120
docs/integrations/dnd-scene-description-artifacts.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# D&D Scene Description Artifact Contract
|
||||
|
||||
This document defines the durable `dnd/scene-description-list` artifact
|
||||
emitted by the D&D scene-description lane.
|
||||
|
||||
## Artifact identity
|
||||
|
||||
| Property | Value |
|
||||
| --- | --- |
|
||||
| Artifact kind | `dnd/scene-description-list` |
|
||||
| Schema ID | `notarius.dnd.scene_descriptions` |
|
||||
| Schema name | `notarius_dnd_scene_descriptions_v1` |
|
||||
| Schema version | `v1` |
|
||||
| Media type | `application/json` |
|
||||
|
||||
The normalized payload is written by the JSON output module to
|
||||
`lanes/<lane-id>.json`. See [JSON output](json-output.md) for the surrounding
|
||||
output bundle.
|
||||
|
||||
## JSON shape
|
||||
|
||||
The payload is a JSON object containing exactly one required field, `scenes`.
|
||||
Each scene object contains exactly these required fields:
|
||||
|
||||
| Field | Shape and ownership |
|
||||
| --- | --- |
|
||||
| `id` | Non-empty accepted chunk ID, assigned by Notarius. |
|
||||
| `source_ref` | Exact inclusive accepted chunk range, assigned by Notarius. |
|
||||
| `kind` | One of `combat`, `narrative`, `recap`, or `meta`. |
|
||||
| `title` | Non-empty, trimmed, source-grounded title. |
|
||||
| `summary` | Non-empty, trimmed, source-grounded summary. |
|
||||
|
||||
All object layers reject unknown fields. The `scenes` array must be present and
|
||||
non-empty. `source_ref` has exactly `source_id`, `start_unit_id`, and
|
||||
`end_unit_id`; its source ID is non-empty and its unit IDs are positive
|
||||
integers.
|
||||
|
||||
```json
|
||||
{
|
||||
"scenes": [
|
||||
{
|
||||
"id": "chunk-000001",
|
||||
"source_ref": {
|
||||
"source_id": "session-alpha",
|
||||
"start_unit_id": 1,
|
||||
"end_unit_id": 3
|
||||
},
|
||||
"kind": "narrative",
|
||||
"title": "Arrival at the watchtower",
|
||||
"summary": "The party reaches the ruined watchtower and begins to investigate it."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`id` and `source_ref` are application-owned identity and evidence. The model
|
||||
provides only `kind`, `title`, and `summary`; it is not asked for chunk IDs,
|
||||
source IDs, unit IDs, ranges, participants, or confidence.
|
||||
|
||||
## Scene kinds
|
||||
|
||||
| Kind | Meaning |
|
||||
| --- | --- |
|
||||
| `combat` | Active combat is a substantive central activity. |
|
||||
| `narrative` | Current-session in-world play that is not principally combat, recap, or meta discussion. |
|
||||
| `recap` | The table is primarily recounting a previous session. |
|
||||
| `meta` | Sustained out-of-character discussion is the scene's primary purpose. |
|
||||
|
||||
For a mixed accepted chunk, classification prefers `combat`, then `recap`,
|
||||
then `meta`, then `narrative`. Brief table talk, rules clarification, or a
|
||||
short recollection does not replace the main current-session activity.
|
||||
|
||||
## Extraction and evidence
|
||||
|
||||
The extractor runs once for each accepted chunk and maps one successful model
|
||||
response to one scene record. It copies the current chunk ID and exact chunk
|
||||
range, preserves the model kind without repair, and trims only surrounding
|
||||
whitespace from title and summary. A model response cannot represent an empty
|
||||
result; extraction failure follows the configured retry and rejection policy.
|
||||
|
||||
Optional `players`, `party`, and `glossary` campaign references can help
|
||||
disambiguate names or setting terms. They never supply scene evidence or add
|
||||
events absent from the accepted chunk. The lane requires no NPC registry or
|
||||
other generated artifact reference.
|
||||
|
||||
## Merge and normalization
|
||||
|
||||
Accepted per-chunk lists are appended in chunk order. Normalization then:
|
||||
|
||||
1. validates the current-source range, non-empty ID and prose, and closed kind;
|
||||
2. trims only title and summary whitespace;
|
||||
3. sorts records by source-document start position, then ID;
|
||||
4. removes records only when all five durable fields are identical;
|
||||
5. rejects a reused ID when any remaining durable field differs; and
|
||||
6. rejects the same exact range when `kind`, `title`, or `summary` differs.
|
||||
|
||||
Two different IDs with the same range and identical model-owned content remain
|
||||
separate records. Normalization does not join adjacent ranges, rewrite prose,
|
||||
repair kinds, infer missing scenes, or use chunk annotations.
|
||||
|
||||
## Validation and warnings
|
||||
|
||||
Extraction validation requires exactly one record with an ID and range exactly
|
||||
equal to its current chunk. Later validation checks source membership without a
|
||||
current chunk. Shape, source-range, kind, ID, and normalized-invariant failures
|
||||
reject the artifact.
|
||||
|
||||
Relatedness checks are advisory. They separately warn when a scene title or
|
||||
summary has no significant lexical token in its cited transcript range. The
|
||||
check ignores short tokens and common function words, uses transcript text only,
|
||||
and does not treat campaign references as evidence. Warning diagnostics are
|
||||
bounded and do not copy transcript or campaign-reference content.
|
||||
|
||||
## Configuration
|
||||
|
||||
Use `dnd/scenes` to form accepted scene chunks, then select
|
||||
`dnd/scene-descriptions` for extraction and normalization. The maintained
|
||||
[complete D&D configuration](../../examples/dnd-complete.config.yml) includes
|
||||
this lane alongside the other D&D artifacts. Selectable keys and default
|
||||
validator chains are defined in [Configuration](../config.md).
|
||||
@@ -1,126 +1,149 @@
|
||||
# D&D Spell-Cast Artifacts
|
||||
# D&D Spell Artifact
|
||||
|
||||
This document is the durable artifact contract for approved
|
||||
`dnd.spell_cast` artifacts produced by the implemented `dnd/spells` extractor.
|
||||
This document is the durable serialized artifact contract for the production
|
||||
D&D spell extractor. Selectable extractor keys are cataloged in
|
||||
[Configuration](../config.md#implemented-production-modules).
|
||||
|
||||
## Artifact Identity
|
||||
## Identity
|
||||
|
||||
- Extractor key: `dnd/spells`
|
||||
- Artifact type: `dnd.spell_cast`
|
||||
- Schema version: `v1`
|
||||
- Artifact kind: `dnd/spell-list`
|
||||
- Prompt ID: `dnd.spells`
|
||||
- Response schema key: `dnd_spells`
|
||||
- Response schema ID: `notarius.dnd.spells`
|
||||
- Response schema name: `notarius_dnd_spells_v1`
|
||||
- Response schema version: `v1`
|
||||
- Media type: `application/json`
|
||||
|
||||
The extractor requires source chunks and transcript source capability. It
|
||||
returns generic artifact candidates that are serialized by the JSON output
|
||||
module.
|
||||
The durable JSON Schema is owned by the D&D spell artifact codec. The
|
||||
extractor's private LLM response schema is a separate transport contract: its
|
||||
source-reference objects omit `source_id`, which the extractor assigns while
|
||||
mapping the response to the canonical artifact. The LLM DTO and transport
|
||||
schema are not part of this durable contract. The private schema owns required
|
||||
fields, JSON types, object and array shapes, and unknown-field rejection;
|
||||
deterministic validators own the durable artifact's semantic constraints.
|
||||
|
||||
## Artifact Envelope
|
||||
The output contains canonical spell casts derived from transcript evidence.
|
||||
Source IDs are assigned from the input identity; source-unit ranges identify
|
||||
the evidence location.
|
||||
|
||||
Approved artifacts use the generic artifact envelope documented in
|
||||
[JSON Output](json-output.md#artifact-files):
|
||||
## Output Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"extractor_key": "dnd/spells",
|
||||
"artifact_type": "dnd.spell_cast",
|
||||
"schema_version": "v1",
|
||||
"payload": {
|
||||
"caster": "Aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "heals an injured ally",
|
||||
"narrative_description": "Aria raises her holy symbol and casts Cure Wounds."
|
||||
},
|
||||
"source_refs": [
|
||||
{
|
||||
"source_id": "session-alpha",
|
||||
"start_unit_id": "seg-001",
|
||||
"end_unit_id": "seg-001"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
The extractor payload is a JSON object with one required top-level array. Its
|
||||
structure is:
|
||||
|
||||
## Payload Fields
|
||||
|
||||
The `payload` object contains:
|
||||
|
||||
- `caster`: in-world character or creature casting the spell;
|
||||
- `spell`: spell name;
|
||||
- `effect`: concise spell effect in the scene;
|
||||
- `narrative_description`: short description of the spell cast in context.
|
||||
|
||||
All payload fields are strings and must be non-empty after trimming.
|
||||
|
||||
`caster` is the in-world caster, not the transcript speaker.
|
||||
|
||||
## Source References
|
||||
|
||||
Source references live on the artifact envelope as `source_refs`; they are not
|
||||
duplicated inside the `payload`.
|
||||
|
||||
Each source reference uses the generic source-reference shape:
|
||||
|
||||
- `source_id`
|
||||
- `start_unit_id`
|
||||
- `end_unit_id`
|
||||
|
||||
Validation requires:
|
||||
|
||||
- at least one source reference;
|
||||
- non-empty source ID and unit IDs;
|
||||
- source ID matching the source document ID;
|
||||
- start and end unit IDs existing in the source document;
|
||||
- start unit appearing before or at the same position as end unit.
|
||||
|
||||
## Structured LLM Response Shape
|
||||
|
||||
The extractor asks the LLM for this top-level response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"spell_casts": [
|
||||
{
|
||||
"caster": "Aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "heals an injured ally",
|
||||
"narrative_description": "Aria raises her holy symbol and casts Cure Wounds.",
|
||||
"source_refs": [
|
||||
{
|
||||
"source_id": "session-alpha",
|
||||
"start_unit_id": "seg-001",
|
||||
"end_unit_id": "seg-001"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```text
|
||||
{"spell_casts": [<spell-cast object>, ...]}
|
||||
```
|
||||
|
||||
`spell_casts` must be present. It may be empty when no spell casts are found.
|
||||
When multiple chunk results are combined, spell casts remain in chunk order.
|
||||
When the payload is written as durable output, its logical path is derived from
|
||||
the configured artifact lane ID as defined by the
|
||||
[JSON output contract](json-output.md#output-payload-files).
|
||||
|
||||
The response schema asset is embedded at
|
||||
`internal/modules/extract/dnd/spells/assets/schemas/dnd_spells.v1.json`.
|
||||
## Spell-Cast Fields
|
||||
|
||||
## Validators
|
||||
Each spell cast contains exactly these required fields:
|
||||
|
||||
The extractor supplies two deterministic validators by default:
|
||||
- `caster`: in-world character or creature casting the spell;
|
||||
- `spell`: spell name;
|
||||
- `source_refs`: transcript source references with extractor-assigned source
|
||||
IDs and evidence unit ranges. It must contain at least one entry.
|
||||
|
||||
- `dnd/spells/shape`
|
||||
- `dnd/spells/source_refs`
|
||||
Both string fields must be non-empty. `caster` is the canonical in-world
|
||||
caster, not the human player, transcript speaker, or GM when the associated
|
||||
character or creature can be identified. Player and party references may
|
||||
disambiguate that identity, but do not independently establish that a cast
|
||||
occurred. The `spell` value must resolve through the effective SRD-plus-overlay
|
||||
catalog as either a canonical name or alias. Catalog validation accepts aliases
|
||||
but does not rewrite them; unknown fields are rejected.
|
||||
|
||||
Rejection reason codes:
|
||||
The artifact includes an actual casting event or an unambiguous declared
|
||||
casting attempt. Spell mentions, hypothetical plans, rules discussion, and
|
||||
catalog matches without a casting event are excluded. The spell catalog is a
|
||||
name-recognition policy and never evidence that a cast occurred.
|
||||
|
||||
- `invalid_payload`: payload JSON cannot be decoded as a spell-cast payload.
|
||||
- `missing_required_field`: `caster`, `spell`, `effect`, or
|
||||
`narrative_description` is blank.
|
||||
- `missing_source_ref`: candidate has no source references.
|
||||
- `invalid_source_ref`: at least one source reference fails generic source
|
||||
reference validation.
|
||||
## Source References
|
||||
|
||||
Rejected candidates are written to `rejected.json` by the JSON output module.
|
||||
Each source reference contains exactly three required fields: `source_id`,
|
||||
`start_unit_id`, and `end_unit_id`. The source ID must match the input identity.
|
||||
The unit IDs must be positive integers present in the input, and the start unit
|
||||
must not appear after the end unit. Unknown fields are rejected.
|
||||
|
||||
For each cast, the complete `source_refs` collection identifies the transcript
|
||||
evidence for the caster, spell name, and occurrence of the cast or declared
|
||||
attempt. The deterministic validators establish that ranges are structurally
|
||||
valid and that the spell name is related to cited text. Semantic evidence
|
||||
sufficiency is an extraction policy and remains subject to evaluation rather
|
||||
than deterministic proof.
|
||||
|
||||
Reference slot keys and accepted file types are defined in
|
||||
[Configuration](../config.md#implemented-production-modules). References are
|
||||
supporting disambiguation material, not source evidence, and are not
|
||||
addressable through `source_refs`.
|
||||
|
||||
## Optional NPC Grounding
|
||||
|
||||
The `dnd/spells` extractor accepts an optional `npcs` reference containing one
|
||||
normalized NPC artifact as `application/json`, up to 1 MiB. An external file is
|
||||
validated during preparation; an ordered generated binding is validated at the
|
||||
step handoff. Both paths use the approved NPC codec and identity policy,
|
||||
re-encode canonical durable JSON for registry provenance, and supply only the
|
||||
registry's ordered names as the operation-time spell prompt input. It helps the
|
||||
model prefer canonical caster names; it does not establish that a spell was
|
||||
cast.
|
||||
|
||||
NPC source references may identify the run that produced the registry or any
|
||||
other session. They remain registry provenance and are never copied into a
|
||||
spell cast's `source_refs`; every spell evidence range must still identify the
|
||||
current transcript. Generated provenance records producer and canonical
|
||||
artifact identity without payload content or a path. When the slot is absent,
|
||||
the prompt receives exactly `{"npcs":[]}` with its projection digest, and the
|
||||
run has no NPC reference provenance.
|
||||
|
||||
## Normalization Behavior
|
||||
|
||||
When the `dnd/spells` normalizer is selected, each recognized spell name is
|
||||
rewritten to the effective catalog's canonical display name. Lookup uses the
|
||||
catalog's case-insensitive, whitespace-normalizing, apostrophe-normalizing, and
|
||||
alias rules. Unknown names are preserved exactly for the normalize validators;
|
||||
the normalizer does not guess or apply fuzzy matching.
|
||||
|
||||
Each cast's `source_refs` is copied, sorted by exact `source_id`,
|
||||
`start_unit_id`, and `end_unit_id`, and stripped of exact structural
|
||||
duplicates. Adjacent or overlapping ranges are not merged, and the normalizer
|
||||
does not synthesize references or change their boundaries.
|
||||
|
||||
After those per-cast changes, duplicate identity requires the same canonical
|
||||
spell name, the same caster after case folding and whitespace normalization,
|
||||
and the same complete, non-empty set of source references valid for the source
|
||||
document. Only the first occurrence is retained, in stable order. Its caster
|
||||
and canonical references are preserved. Unknown names, empty or invalid
|
||||
evidence, and casts with different evidence remain separate.
|
||||
|
||||
Mutation and duplicate decisions are returned through the normal warnings
|
||||
surface. Warning scopes use the merged input index, such as `spell_casts[0]`,
|
||||
so they remain meaningful even when a later duplicate is removed. The
|
||||
normalizer uses these reason codes:
|
||||
|
||||
| Reason code | Meaning |
|
||||
| --- | --- |
|
||||
| `spell_name_canonicalized` | A catalog lookup replaced an input name with its canonical display name. |
|
||||
| `spell_name_unresolved` | A name was not found in the effective catalog and was retained unchanged. |
|
||||
| `source_references_normalized` | Reference order changed or exact duplicate references were removed. |
|
||||
| `duplicate_spell_cast_collapsed` | A later cast matched the retained cast's complete duplicate identity. |
|
||||
|
||||
Only warnings from an accepted normalize attempt are promoted to
|
||||
`warnings.json`. If an unresolved name reaches the default normalize validator
|
||||
chain, the catalog validator rejects the candidate with `unknown_spell`; the
|
||||
`spell_name_unresolved` warning remains in the attempt's debug artifact. An
|
||||
explicit validator override that accepts the candidate promotes the unresolved
|
||||
warning normally.
|
||||
|
||||
The default extraction and normalization chains both preserve this registered
|
||||
order: JSON syntax, spell shape, catalog membership, source references, JSON
|
||||
Schema, then source relatedness. Extraction validates the private response
|
||||
schema; normalization validates the durable artifact schema.
|
||||
|
||||
## Manifest Metadata
|
||||
|
||||
@@ -138,15 +161,44 @@ manifest metadata:
|
||||
"response_schema_id": "notarius.dnd.spells",
|
||||
"response_schema_name": "notarius_dnd_spells_v1",
|
||||
"response_schema_version": "v1",
|
||||
"response_schema_sha256": "sha256:..."
|
||||
"response_schema_sha256": "sha256:...",
|
||||
"catalog_base_id": "dnd-5e-2014-srd-spells",
|
||||
"catalog_digest": "sha256:...",
|
||||
"catalog_overlay_ids": ["campaign.example"],
|
||||
"npc_registry_digest": "sha256:...",
|
||||
"npc_count": 3
|
||||
},
|
||||
"normalizer": {
|
||||
"catalog_base_id": "dnd-5e-2014-srd-spells",
|
||||
"catalog_digest": "sha256:...",
|
||||
"catalog_overlay_ids": ["campaign.example"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Raw prompt and schema content are not included in manifest metadata.
|
||||
`catalog_digest` identifies the effective semantic catalog, while
|
||||
`catalog_overlay_ids` is sorted and empty for a base-only configuration. Raw
|
||||
prompt, schema, catalog, alias, and local overlay-file content are not
|
||||
included in manifest metadata. The `normalizer` metadata uses the same catalog
|
||||
identity fields when that module is selected. Overlay origin, media type, byte
|
||||
size, and raw digest are recorded separately in the manifest's reference
|
||||
provenance; see the [JSON output contract](json-output.md#manifestjson).
|
||||
|
||||
## Compatibility Limit
|
||||
The `npc_registry_digest` and `npc_count` fields in the example are present for
|
||||
an external NPC registry when the extractor publishes its prepared module
|
||||
metadata. They contain no NPC names, source references, paths, or raw
|
||||
bytes. A generated registry's identity is instead represented by the framework
|
||||
handoff provenance and dependency fingerprint, so the consumer module metadata
|
||||
does not duplicate it.
|
||||
|
||||
This contract covers only `dnd.spell_cast` artifacts produced by the
|
||||
implemented spell-cast extractor.
|
||||
The extractor's prompt hash, private response-schema hash, and effective catalog
|
||||
digest also contribute independently scoped semantic checkpoint fingerprints.
|
||||
Changing any of those prepared contracts intentionally produces a cold
|
||||
checkpoint miss. Fingerprints contain only digests, never prompt, schema,
|
||||
catalog, or reference content. When an NPC registry is bound, its semantic
|
||||
digest contributes an additional local `npc_registry` fingerprint for an
|
||||
external binding; the manifest metadata contains only that digest and
|
||||
`npc_count`. Raw NPC file provenance remains independently recorded in the
|
||||
manifest's `references` list. Generated bindings contribute the canonical
|
||||
artifact dependency fingerprint and bounded producer provenance instead.
|
||||
|
||||
70
docs/integrations/dnd-spell-catalog-overlays.md
Normal file
70
docs/integrations/dnd-spell-catalog-overlays.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# D&D Spell-Catalog Overlay Contract
|
||||
|
||||
This document defines the JSON format accepted by the D&D spell catalog
|
||||
resolver. An overlay supplies campaign-specific spell names and aliases for
|
||||
recognition. It does not supply spell rules, levels, classes, effects, or
|
||||
source evidence.
|
||||
|
||||
The `dnd/spells` extractor accepts one optional UTF-8 `application/json` overlay
|
||||
bundle through its `spell_catalog` reference slot. The framework materializes
|
||||
that file relative to the configuration or command-line binding, enforces the
|
||||
1 MiB slot limit, and records its origin and raw digest separately from the
|
||||
effective catalog digest.
|
||||
|
||||
## Shape
|
||||
|
||||
An overlay bundle has this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "notarius.dnd.spell-catalog-overlay.v1",
|
||||
"catalogs": [
|
||||
{
|
||||
"id": "campaign.example",
|
||||
"ruleset": "dnd-5e-2014",
|
||||
"source": {
|
||||
"title": "Example campaign spells",
|
||||
"version": "1",
|
||||
"url": "",
|
||||
"license": ""
|
||||
},
|
||||
"spells": [
|
||||
{
|
||||
"name": "Aegis of Emberfall",
|
||||
"aliases": ["Emberfall Aegis"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The top-level `schema_version` and `catalogs` fields are required. The schema
|
||||
version must be exactly `notarius.dnd.spell-catalog-overlay.v1`, and at least
|
||||
one catalog is required. Catalogs require a unique, non-empty, trimmed `id`,
|
||||
the exact `dnd-5e-2014` `ruleset`, a `source`, and a non-empty `spells` array.
|
||||
|
||||
`source.title` is required and must be non-empty and trimmed. `source.version`,
|
||||
`source.url`, and `source.license` are optional strings and may be empty.
|
||||
Each spell requires a non-empty, trimmed `name`. `aliases` may be omitted or
|
||||
may be an array of trimmed, non-empty strings; JSON `null` is not an alias
|
||||
array. Overlay objects contain no other supported spell fields.
|
||||
|
||||
Decoding is strict: unknown fields, malformed JSON, trailing JSON values, and
|
||||
non-string optional source fields are rejected.
|
||||
|
||||
## Composition
|
||||
|
||||
The resolver always starts with the embedded D&D 5e 2014 SRD catalog. Overlay
|
||||
catalogs are sorted by `id` before composition, so the input order does not
|
||||
affect the result. A new canonical name adds a recognition entry. A canonical
|
||||
name matching an existing canonical name augments that spell and keeps the
|
||||
established canonical display spelling. Repeated aliases for the same spell
|
||||
are idempotent.
|
||||
|
||||
Canonical-name display conflicts and canonical/alias or alias/alias collisions
|
||||
between different spells are errors, including collisions with the embedded
|
||||
catalog. Canonical names and aliases use the catalog's case, whitespace, and
|
||||
common-apostrophe normalization rules. The effective catalog returns canonical
|
||||
names in sorted order and produces a semantic SHA-256 digest that is stable
|
||||
under JSON formatting, object-key, catalog, spell, and alias reordering.
|
||||
@@ -1,30 +1,28 @@
|
||||
# JSON Output
|
||||
|
||||
This document is the durable JSON output file-format contract produced by the
|
||||
implemented `json` output module and written by the CLI.
|
||||
production JSON encoder and written by the CLI. Selectable output-encoder keys
|
||||
are cataloged in
|
||||
[Configuration](../config.md#implemented-production-modules).
|
||||
|
||||
## Output Directory
|
||||
|
||||
The CLI writes logical output files under:
|
||||
|
||||
```text
|
||||
<output-root>/<run-id>/
|
||||
```
|
||||
|
||||
The default output root is `./notarius-output`. Operational behavior is covered
|
||||
in [Operations](../operations.md).
|
||||
The output module produces the logical bundle described here. The CLI's
|
||||
physical placement and lifecycle for that bundle are defined in
|
||||
[Operations](../operations.md#output-directory).
|
||||
|
||||
## Files
|
||||
|
||||
The `json` output module writes:
|
||||
The encoder writes:
|
||||
|
||||
- `index.json`
|
||||
- `manifest.json`
|
||||
- `artifacts/<artifact-type>.json`, one file per approved artifact type
|
||||
- `lanes/<lane-id>.json`, one file per normalized serialized artifact
|
||||
- `rejected.json`
|
||||
- `warnings.json`
|
||||
- `chunk-map.json`, only when the JSON output binding enables
|
||||
`include_chunk_map` and the run has an accepted chunk map
|
||||
|
||||
Files are pretty-printed JSON with a trailing newline.
|
||||
Files are pretty-printed JSON with a trailing newline when the payload is JSON.
|
||||
Logical file paths are relative, slash-separated, and may not contain `..`.
|
||||
|
||||
## `index.json`
|
||||
|
||||
@@ -33,10 +31,15 @@ Shape:
|
||||
```json
|
||||
{
|
||||
"manifest_file": "manifest.json",
|
||||
"artifact_files": [
|
||||
"output_files": [
|
||||
{
|
||||
"artifact_type": "dnd.spell_cast",
|
||||
"file": "artifacts/dnd.spell_cast.json"
|
||||
"lane_id": "spells",
|
||||
"media_type": "application/json",
|
||||
"file": "lanes/spells.json",
|
||||
"module_key": "noop",
|
||||
"schema_id": "notarius.dnd.spells",
|
||||
"schema_name": "notarius_dnd_spells_v1",
|
||||
"schema_version": "v1"
|
||||
}
|
||||
],
|
||||
"rejected_file": "rejected.json",
|
||||
@@ -44,25 +47,37 @@ Shape:
|
||||
}
|
||||
```
|
||||
|
||||
`artifact_files` is sorted by artifact type. It is empty when no artifacts are
|
||||
approved.
|
||||
`output_files` is sorted by lane ID. Output file names are produced by
|
||||
sanitizing the lane ID:
|
||||
|
||||
- characters outside `A-Z`, `a-z`, `0-9`, `.`, `_`, and `-` become `_`;
|
||||
- repeated `..` sequences are replaced;
|
||||
- leading and trailing `.`, `_`, and `-` are trimmed;
|
||||
- empty sanitized names are rejected;
|
||||
- two lanes that sanitize to the same output file are rejected.
|
||||
|
||||
`manifest_file`, `rejected_file`, and `warnings_file` contain the fixed paths
|
||||
shown above. Each `output_files` entry requires `lane_id` and `file`. It also
|
||||
contains the normalized payload `media_type`, normalizer `module_key`, and
|
||||
response `schema_id`, `schema_name`, and `schema_version` when those values are
|
||||
available.
|
||||
|
||||
When present, the top-level optional `chunk_map` descriptor contains exactly
|
||||
`artifact_kind`, `file`, `media_type`, `schema_id`, `schema_name`, and
|
||||
`schema_version`. It identifies the pipeline-wide `chunk-map.json`; it is not
|
||||
a lane output and never appears in `output_files`. The descriptor and file are
|
||||
both absent when export is disabled or no chunk plan was accepted. Its payload
|
||||
contract is defined by [Accepted Chunk Map](chunk-map.md).
|
||||
|
||||
## `manifest.json`
|
||||
|
||||
`manifest.json` contains a run manifest:
|
||||
`manifest.json` contains a run manifest. This abridged example shows its core
|
||||
structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "run-123",
|
||||
"pipeline_id": "dnd-session",
|
||||
"pipeline_digest": "sha256:...",
|
||||
"input_module": "seriatim",
|
||||
"chunker": "generic",
|
||||
"source_digests": ["sha256:..."],
|
||||
"extractors": ["dnd/spells"],
|
||||
"merger": "appendorder",
|
||||
"normalizer": "noop",
|
||||
"output_encoder": "json",
|
||||
"artifact_lanes": [
|
||||
{
|
||||
"id": "spells",
|
||||
@@ -71,13 +86,6 @@ approved.
|
||||
"normalizer": "noop"
|
||||
}
|
||||
],
|
||||
"llm_profiles": [
|
||||
{
|
||||
"id": "default",
|
||||
"provider": "openai-compatible",
|
||||
"model": "configured-model"
|
||||
}
|
||||
],
|
||||
"validation_status": "approved",
|
||||
"started_at": "2026-01-01T00:00:00Z",
|
||||
"completed_at": "2026-01-01T00:00:01Z"
|
||||
@@ -86,52 +94,76 @@ approved.
|
||||
|
||||
Fields with empty values may be omitted by JSON encoding.
|
||||
|
||||
`validation_status` is `approved` when no candidates were rejected and
|
||||
`rejected` when one or more candidates were rejected.
|
||||
The manifest fields are:
|
||||
|
||||
## Artifact Files
|
||||
- `run_id`, `pipeline_id`, and `pipeline_digest`: run and resolved-pipeline
|
||||
identity;
|
||||
- `input_module`, `chunker`, `extractors`, `merger`, `normalizer`, and
|
||||
`output_encoder`: resolved module keys;
|
||||
- `chunk_plan`: payload-free provenance for the effective chunk plan. `mode`
|
||||
is the effective cache mode; `action` is `reused`, `generated`,
|
||||
`refreshed`, or `bypassed` when a plan was materialized. `requested_module`
|
||||
is the current pipeline chunker, while `producer_input_module`,
|
||||
`producer_module`, `producer_llm_profile`, `producer_references`,
|
||||
`producer_metadata`, `source_digest`, `plan_digest`, `plan_schema_version`,
|
||||
and `created_at` describe the stored or generated producer when available.
|
||||
A cached plan can therefore identify a producer different from the requested
|
||||
module. This object never embeds ranges, units, annotations, prompts,
|
||||
responses, or reference content;
|
||||
- `module_metadata` and `artifact_lanes`: module and per-lane provenance,
|
||||
including prompt and response-schema provenance when provided;
|
||||
- `validator_chains`: resolved validation points and validators;
|
||||
- `source_digests` and `references`: source and reference provenance;
|
||||
- `normalized_outputs` and `rejected_outputs`: payload-free result summaries;
|
||||
- `llm_profiles`: selected profile IDs and provider or model names when
|
||||
available;
|
||||
- `metadata`: the effective prompt `session_id`;
|
||||
- `validation_status`: `approved` or `rejected`;
|
||||
- `started_at` and `completed_at`: UTC run timestamps.
|
||||
|
||||
Each artifact file has this shape:
|
||||
`source_digests` contains source document digests only. Bound references are
|
||||
recorded separately under `references`, which contains provenance only: target
|
||||
stage, lane ID when present, slot name, origin type and URI, digest, media
|
||||
type, byte size, and binding source. Reference content is not written to
|
||||
durable output.
|
||||
|
||||
```json
|
||||
{
|
||||
"artifact_type": "dnd.spell_cast",
|
||||
"artifacts": [
|
||||
{
|
||||
"extractor_key": "dnd/spells",
|
||||
"artifact_type": "dnd.spell_cast",
|
||||
"schema_version": "v1",
|
||||
"payload": {},
|
||||
"source_refs": [
|
||||
{
|
||||
"source_id": "session-alpha",
|
||||
"start_unit_id": "seg-001",
|
||||
"end_unit_id": "seg-001"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Reference `stage` is `chunk`, `extract`, `merge`, or `normalize`. `lane_id` is
|
||||
omitted for chunk references and present for extract, merge, and normalize
|
||||
references.
|
||||
|
||||
Artifact envelope fields:
|
||||
`validation_status` is `approved` when no outputs were rejected and `rejected`
|
||||
when one or more outputs were rejected.
|
||||
|
||||
- `extractor_key`: extractor module key.
|
||||
- `artifact_type`: artifact type.
|
||||
- `schema_version`: artifact schema version.
|
||||
- `payload`: artifact-type-specific JSON payload.
|
||||
- `source_refs`: optional generic source references.
|
||||
- `metadata`: optional artifact metadata.
|
||||
Producer warnings and the current run's chunk-validation warnings remain in
|
||||
`warnings.json`. The manifest records only provenance and decision summaries;
|
||||
empty producer-only values are omitted for compatibility with existing readers.
|
||||
|
||||
Artifact file names are produced by sanitizing the artifact type:
|
||||
`validator_chains` records the resolved validator chain for each validation
|
||||
point. Entries include stage, lane ID when applicable, module key, and validators
|
||||
with key and execution class. Empty chains are recorded with an empty
|
||||
`validators` array, including chains resolved from explicit empty config
|
||||
overrides.
|
||||
|
||||
- characters outside `A-Z`, `a-z`, `0-9`, `.`, `_`, and `-` become `_`;
|
||||
- repeated `..` sequences are replaced;
|
||||
- leading and trailing `.`, `_`, and `-` are trimmed;
|
||||
- empty sanitized names are rejected.
|
||||
`normalized_outputs` summarizes each normalized lane output without embedding
|
||||
payload bytes. Entries include lane ID, normalizer module key, source ID, media
|
||||
type, and response schema provenance where available.
|
||||
|
||||
For current D&D spell-cast artifacts, the file is
|
||||
`artifacts/dnd.spell_cast.json`.
|
||||
`rejected_outputs` summarizes rejected module outputs without embedding raw
|
||||
payload bytes. Entries include stage, lane, module, chunk, validator or reason,
|
||||
message, attempt count, and optional diagnostic artifact path.
|
||||
|
||||
## Output Payload Files
|
||||
|
||||
Each normalized serialized artifact is written to
|
||||
`lanes/<sanitized-lane-id>.json`. The JSON output encoder is domain-neutral and
|
||||
accepts only artifacts whose codec media type is `application/json`. The file
|
||||
contains the codec-owned JSON bytes pretty-printed.
|
||||
|
||||
The schema of each lane payload is owned by that artifact contract. For the
|
||||
current D&D lanes, see [D&D Spell Artifact](dnd-spell-artifacts.md),
|
||||
[D&D NPC Artifact](dnd-npc-artifacts.md), and
|
||||
[D&D Combat-Turn Artifact](dnd-combat-turn-artifacts.md), and
|
||||
[D&D Scene Description Artifact](dnd-scene-description-artifacts.md).
|
||||
|
||||
## `rejected.json`
|
||||
|
||||
@@ -139,25 +171,14 @@ Shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"rejected": [
|
||||
{
|
||||
"candidate": {
|
||||
"index": 0,
|
||||
"extractor_key": "dnd/spells",
|
||||
"artifact_type": "dnd.spell_cast",
|
||||
"schema_version": "v1",
|
||||
"payload": {},
|
||||
"source_refs": []
|
||||
},
|
||||
"validator_name": "dnd/spells/source_refs",
|
||||
"reason_code": "missing_source_ref",
|
||||
"message": "spell cast candidate must include at least one source ref"
|
||||
}
|
||||
]
|
||||
"rejected": []
|
||||
}
|
||||
```
|
||||
|
||||
`rejected` is an empty array when no candidates are rejected.
|
||||
When output validation rejects an output, each entry contains `stage` and
|
||||
`message`. It includes `lane_id`, `module_key`, `chunk_id`, `chunk_index`,
|
||||
`validator_name`, `reason_code`, `attempt_count`, and
|
||||
`diagnostic_artifact_path` when applicable.
|
||||
|
||||
## `warnings.json`
|
||||
|
||||
@@ -167,26 +188,14 @@ Shape:
|
||||
{
|
||||
"warnings": [
|
||||
{
|
||||
"scope": "output",
|
||||
"reason_code": "example_warning",
|
||||
"message": "warning message"
|
||||
"scope": "extract",
|
||||
"reason_code": "example",
|
||||
"message": "human-readable warning"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`warnings` is an empty array when no warnings are reported.
|
||||
|
||||
## Path Safety
|
||||
|
||||
The output module returns slash-separated logical paths. The CLI also validates
|
||||
logical output names before writing:
|
||||
|
||||
- names must be non-empty;
|
||||
- names must be relative;
|
||||
- names must be clean;
|
||||
- names must use `/`, not `\`;
|
||||
- names must not contain `..`;
|
||||
- resolved paths must stay under the run output directory.
|
||||
|
||||
Durable writes are atomic per file.
|
||||
Each warning requires `reason_code` and `message`; `scope` is omitted when it is
|
||||
empty.
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# OpenAI-Compatible Structured Output
|
||||
|
||||
This document describes the external LLM provider contract implemented by the
|
||||
production Notarius LLM client.
|
||||
|
||||
## Provider
|
||||
|
||||
- Provider key: `openai-compatible`
|
||||
- HTTP method: `POST`
|
||||
- Endpoint: `<base_url>/chat/completions`
|
||||
- Request body: JSON
|
||||
- Response mode: chat completions with structured JSON schema output
|
||||
|
||||
`base_url` is trimmed of trailing slashes before `/chat/completions` is
|
||||
appended. Configure provider settings in [Configuration](../config.md).
|
||||
|
||||
## Request
|
||||
|
||||
The client sends a JSON object with:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "configured-model",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "..."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "..."
|
||||
}
|
||||
],
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "schema_name",
|
||||
"strict": true,
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implemented request behavior:
|
||||
|
||||
- `model` comes from the structured completion request when set, otherwise from
|
||||
the configured LLM profile.
|
||||
- `messages` must be non-empty; each role and content must be non-empty after
|
||||
trimming.
|
||||
- `response_format.type` is always `json_schema`.
|
||||
- `response_format.json_schema.strict` is always `true`.
|
||||
- `response_format.json_schema.name` and `schema` come from the extractor or
|
||||
validator making the call.
|
||||
|
||||
If an API key is configured, the client sends:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <api-key>
|
||||
```
|
||||
|
||||
The client always sends `Content-Type: application/json`.
|
||||
|
||||
## Response
|
||||
|
||||
The client expects a JSON response with at least one choice:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "provider-model",
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "{\"field\":\"value\"}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 5,
|
||||
"total_tokens": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`choices[0].message.content` may be either:
|
||||
|
||||
- a JSON string whose contents are valid JSON; or
|
||||
- raw JSON.
|
||||
|
||||
The decoded content is unmarshaled into the caller-provided structured output
|
||||
target. If `usage` is present, prompt, completion, and total token counts are
|
||||
copied into the completion response.
|
||||
|
||||
## Errors And Retries
|
||||
|
||||
The client validates base URL, model, response schema name, response schema
|
||||
JSON, messages, and output target before or during the call.
|
||||
|
||||
Retryable failures:
|
||||
|
||||
- HTTP request failure;
|
||||
- response body read failure;
|
||||
- HTTP `429`;
|
||||
- HTTP `5xx`;
|
||||
- malformed provider response envelope;
|
||||
- missing choices;
|
||||
- missing, empty, or invalid assistant JSON content;
|
||||
- structured-output decode failure.
|
||||
|
||||
Non-retryable provider status codes include non-`429` `4xx` responses.
|
||||
|
||||
Provider error bodies are parsed for `error.message` or `message` when present.
|
||||
Configured API key values and bearer-token values are redacted from returned
|
||||
provider errors.
|
||||
|
||||
## Timeouts And Concurrency
|
||||
|
||||
The configured profile timeout is applied per provider request when greater
|
||||
than zero. Context cancellation is respected.
|
||||
|
||||
The production CLI wraps the provider client with the LLM scheduler. Effective
|
||||
concurrency is described in [LLM runtime internals](../internal/llm.md).
|
||||
|
||||
## Limits
|
||||
|
||||
This contract documents only the fields the implemented client sends and reads.
|
||||
Provider-specific extensions are ignored unless they affect those fields.
|
||||
@@ -1,116 +1,65 @@
|
||||
# Seriatim Transcript JSON
|
||||
|
||||
This document is the external input contract for the implemented `seriatim`
|
||||
input adapter.
|
||||
This document is the external input contract consumed by the production
|
||||
Seriatim input adapter. Selectable input-adapter keys are cataloged in
|
||||
[Configuration](../config.md#implemented-production-modules).
|
||||
|
||||
## Adapter
|
||||
|
||||
- Module key: `seriatim`
|
||||
- Document kind: `transcript`
|
||||
- Unit kind: `transcript_segment`
|
||||
- Source format: `application/vnd.seriatim+json`
|
||||
|
||||
The adapter parses raw Seriatim JSON into a generic source document. It owns
|
||||
transcript-specific JSON parsing and metadata mapping; core source and pipeline
|
||||
code stay source-format agnostic.
|
||||
|
||||
## Accepted Shape
|
||||
|
||||
The input must be one JSON object with top-level `metadata` and `segments`
|
||||
fields. This covers the maintained minimal fixture and Seriatim intermediate
|
||||
output that provides the same required segment fields.
|
||||
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"id": "session-alpha",
|
||||
"title": "Synthetic D&D spell session"
|
||||
},
|
||||
"segments": [
|
||||
{
|
||||
"id": "seg-001",
|
||||
"start": 0,
|
||||
"end": 4,
|
||||
"speaker": "Aria",
|
||||
"text": "Aria raises her holy symbol and casts Cure Wounds."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The maintained example is
|
||||
[examples/seriatim-minimal-transcript.json](../../examples/seriatim-minimal-transcript.json).
|
||||
|
||||
Top-level metadata entries are preserved. Other segment fields, such as
|
||||
`categories`, are ignored.
|
||||
Required top-level fields:
|
||||
|
||||
- `metadata`: an object. Its entries are accepted as source metadata.
|
||||
- `segments`: a non-empty array of segment objects.
|
||||
|
||||
Required segment fields:
|
||||
|
||||
- `id`: a positive integer JSON number or canonical decimal string without
|
||||
leading zeros or surrounding whitespace;
|
||||
- `start`: a finite, non-negative JSON number or numeric string;
|
||||
- `end`: a finite, non-negative JSON number or numeric string that is not less
|
||||
than `start`;
|
||||
- `speaker`: a non-empty string;
|
||||
- `text`: a non-empty string.
|
||||
|
||||
Other top-level and segment fields, such as `categories`, are ignored.
|
||||
|
||||
Multiple top-level JSON values are rejected.
|
||||
|
||||
## Validation
|
||||
|
||||
The adapter rejects:
|
||||
|
||||
- empty raw input;
|
||||
- malformed JSON;
|
||||
- top-level JSON that is not an object;
|
||||
- missing, null, or non-object `metadata`;
|
||||
- missing, null, non-array, or empty `segments`;
|
||||
- segment values that are not objects;
|
||||
- segment `id` values that are neither strings nor numbers;
|
||||
- non-string `speaker` or `text`;
|
||||
- empty segment IDs;
|
||||
- segment IDs with leading or trailing whitespace;
|
||||
- duplicate segment IDs;
|
||||
- missing or empty `speaker`;
|
||||
- missing, empty, invalid, non-finite, or negative `start`;
|
||||
- missing, empty, invalid, non-finite, or negative `end`;
|
||||
- `end` values before `start`;
|
||||
- missing or empty `text`.
|
||||
The adapter rejects empty input, malformed JSON, multiple top-level JSON values,
|
||||
non-object segment values, duplicate segment IDs, and any violation of the
|
||||
shape or field constraints above.
|
||||
|
||||
Segment text is preserved as provided, but it must not be empty after trimming.
|
||||
|
||||
## Source Mapping
|
||||
## Derived Identity
|
||||
|
||||
The adapter maps input to `SourceDocument`:
|
||||
Notarius identifies the parsed source in this order:
|
||||
|
||||
- `metadata` becomes `SourceDocument.Metadata`;
|
||||
- `SourceDocument.Kind` is `transcript`;
|
||||
- `SourceDocument.Format` is `application/vnd.seriatim+json`;
|
||||
- `SourceDocument.Digest` is `sha256:<hex>` of the exact raw input bytes.
|
||||
1. `metadata.id`, when it is a non-empty string after trimming;
|
||||
2. `metadata.source_id`, when it is a non-empty string after trimming;
|
||||
3. `seriatim:<first-16-hex-chars-of-raw-sha256>`.
|
||||
|
||||
`SourceDocument.ID` is selected in this order:
|
||||
|
||||
1. the parse request source ID, after trimming;
|
||||
2. `metadata.id`, when it is a non-empty string after trimming;
|
||||
3. `metadata.source_id`, when it is a non-empty string after trimming;
|
||||
4. `seriatim:<first-16-hex-chars-of-raw-sha256>`.
|
||||
|
||||
Each segment becomes one `SourceUnit`:
|
||||
|
||||
- `segment.id` becomes `SourceUnit.ID`; numeric IDs are converted to their JSON
|
||||
number text, so `1` becomes `"1"`;
|
||||
- `segment.text` becomes `SourceUnit.Text`;
|
||||
- `SourceUnit.Kind` is `transcript_segment`;
|
||||
- `speaker`, `start`, and `end` are stored in source-unit metadata.
|
||||
|
||||
## Metadata Keys
|
||||
|
||||
Seriatim unit metadata uses these keys:
|
||||
|
||||
- `speaker`: string speaker label;
|
||||
- `start`: `json.Number` start value;
|
||||
- `end`: `json.Number` end value.
|
||||
|
||||
The `internal/modules/input/seriatim` package exposes typed accessors for these
|
||||
values.
|
||||
|
||||
## Capabilities
|
||||
|
||||
The module declares these provided capabilities:
|
||||
|
||||
- `source.transcript`
|
||||
- `transcript.speaker`
|
||||
- `transcript.timestamps`
|
||||
The exact raw input SHA-256 remains the basis of the fallback source ID. The
|
||||
source digest recorded in output provenance is instead the SHA-256 of the
|
||||
canonical generic source document, excluding the digest field itself. It covers
|
||||
the derived source identity, document kind and format, ordered units and their
|
||||
self-references, and accepted metadata. Segment IDs become the unit IDs used by
|
||||
artifact source references; each produced unit carries a self-reference whose
|
||||
source ID is the derived document ID and whose start and end IDs both equal the
|
||||
segment ID.
|
||||
|
||||
## Compatibility Limit
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
# Diagnostics Internals
|
||||
|
||||
Diagnostics internals live in `internal/core/diagnostics`. Operator-facing run
|
||||
behavior is documented in [Operations](../operations.md).
|
||||
|
||||
## Purpose
|
||||
|
||||
Diagnostics provide local inspection artifacts for a run without becoming the
|
||||
durable output contract. Durable user output is produced by output modules and
|
||||
written by the CLI.
|
||||
|
||||
Diagnostics must not expose secrets.
|
||||
|
||||
## Run Directory
|
||||
|
||||
`NewRunDirectory(workDir, retention)` creates:
|
||||
|
||||
```text
|
||||
<workDir>/run-<unix-nanoseconds>/
|
||||
```
|
||||
|
||||
If `workDir` is empty, it defaults to `/tmp/notarius`. Empty retention defaults
|
||||
to `auto`.
|
||||
|
||||
The writer makes the work directory if needed, then attempts to create a unique
|
||||
run directory. It retries run ID creation a bounded number of times if a
|
||||
collision occurs.
|
||||
|
||||
## Artifact Writers
|
||||
|
||||
Implemented artifact names:
|
||||
|
||||
- `invocation.json`
|
||||
- `effective-config.json`
|
||||
- `resolved-pipeline.json`
|
||||
- `source-document.json`
|
||||
- `run-manifest.json`
|
||||
- `run-report.json`
|
||||
- `warnings.json`
|
||||
- `error.log`
|
||||
|
||||
JSON artifacts are encoded with indentation and a trailing newline. Writes are
|
||||
atomic through a temporary file in the target directory followed by rename.
|
||||
|
||||
Artifact names must be single relative file names. Absolute paths, path
|
||||
separators, and names resolving outside the run directory are rejected.
|
||||
|
||||
## Redacted Effective Config
|
||||
|
||||
Diagnostics writers accept payloads that implement
|
||||
`RedactedDiagnosticsPayload`. `internal/core/config` uses this to redact API
|
||||
keys in effective config diagnostics while preserving resolved pipeline context.
|
||||
|
||||
The redaction path clones config data before replacing secret values.
|
||||
|
||||
## Retention
|
||||
|
||||
Retention is decided by `ShouldRetainRunDirectory`.
|
||||
|
||||
- Failed runs are always retained.
|
||||
- `always` retains successful runs.
|
||||
- `never` removes successful runs.
|
||||
- `auto` retains successful runs only when warnings exist.
|
||||
- Unknown retention values are treated as retain by the retention decision, but
|
||||
config validation rejects unsupported values before normal runs.
|
||||
|
||||
`ApplyRetention` removes only the specific run directory.
|
||||
|
||||
## CLI Failure Behavior
|
||||
|
||||
The CLI creates the diagnostics run directory after config loading and before
|
||||
pipeline resolution. Failures before that point do not have diagnostics.
|
||||
|
||||
After diagnostics creation, run failures call `WriteErrorLog` and apply
|
||||
retention with `RunSucceeded: false`, so the run directory remains available.
|
||||
|
||||
When the pipeline returns a partial manifest on failure, the CLI writes that
|
||||
manifest before logging the failure.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Diagnostics paths must be narrow and run-directory scoped.
|
||||
- Writes should be atomic where practical.
|
||||
- Secrets must be redacted.
|
||||
- Diagnostics write failures are command failures because they can hide the
|
||||
information needed for recovery.
|
||||
- Durable output file contracts belong to output modules and integration docs,
|
||||
not to diagnostics.
|
||||
@@ -1,116 +1,235 @@
|
||||
# LLM Runtime
|
||||
# LLM Runtime Internals
|
||||
|
||||
The implemented LLM runtime lives in `internal/framework/llm`. It provides
|
||||
transport-neutral structured completion contracts, an OpenAI-compatible HTTP
|
||||
adapter, concurrency scheduling, schema registry helpers, retry behavior, and
|
||||
secret redaction.
|
||||
`internal/framework/llm` implements Notarius's transport boundary for structured
|
||||
completion. It contains the Scriptorium adapter, concurrency scheduler,
|
||||
prompt/schema registries, selected-profile recording, and provider-error
|
||||
redaction.
|
||||
|
||||
## Contract
|
||||
Provider-neutral ownership rules are defined in
|
||||
[Architecture](../policy/architecture.md#llm-boundary). Profile sources,
|
||||
credentials, and concurrency settings are defined in
|
||||
[Configuration](../config.md).
|
||||
|
||||
Modules depend on `contracts.StructuredLLMClient`:
|
||||
## Structured Contract
|
||||
|
||||
```go
|
||||
CompleteStructured(ctx, request, out) (response, error)
|
||||
```
|
||||
Modules and LLM-backed validators depend on
|
||||
`contracts.StructuredLLMClient.CompleteStructured`. A request identifies a
|
||||
prompt and optional profile/session, supplies named input materials and
|
||||
variables, and provides a caller-owned decoding target. A successful response
|
||||
contains the validated raw structured bytes plus non-secret provider, model,
|
||||
profile, and token metadata.
|
||||
|
||||
The request contains messages, optional model override, response schema name,
|
||||
and response schema JSON. The caller supplies a pointer target for decoded
|
||||
structured output.
|
||||
The caller owns prompt selection, response-schema selection, and interpretation
|
||||
of the decoded result. `LLMInputMaterial` keeps source and reference bytes with
|
||||
their origin metadata so the adapter can pass named artifacts to Scriptorium
|
||||
without exposing Scriptorium types through stage contracts.
|
||||
|
||||
Extractors own prompts and schemas. Provider adapters should not contain
|
||||
domain-specific prompt logic.
|
||||
## Production Construction
|
||||
|
||||
## Production Client Construction
|
||||
`internal/cli` constructs the production runtime by:
|
||||
|
||||
`internal/cli` builds the production LLM client from the effective config:
|
||||
1. allocating the asset registry populated by the generic, Seriatim, and D&D
|
||||
package-family registrars;
|
||||
2. creating a `ScriptoriumClient` from the effective profile source;
|
||||
3. attaching an `LLMProfileRecorder`;
|
||||
4. creating a scheduler from the effective concurrency limit;
|
||||
5. returning a `ScheduledClient` wrapper;
|
||||
6. decorating that shared client before preparation when debug recording is
|
||||
enabled; and
|
||||
7. injecting that one shared client into complete pipeline preparation before
|
||||
the source file is read or the runner is invoked.
|
||||
|
||||
1. find the effective LLM profile;
|
||||
2. build `OpenAICompatibleClientConfig`;
|
||||
3. create an OpenAI-compatible client;
|
||||
4. create a scheduler from profile or global concurrency;
|
||||
5. wrap the client with `NewScheduledClient`;
|
||||
6. return non-secret LLM profile manifest metadata.
|
||||
The D&D scene chunker and spell, NPC, combat-turn, NPC-interaction, and
|
||||
scene-description extractors retain this
|
||||
injected client and use it for every structured completion. Operation requests
|
||||
do not carry an LLM client.
|
||||
|
||||
The current run command requires exactly one distinct effective LLM profile for
|
||||
the resolved pipeline.
|
||||
The CLI separately gathers explicit profile IDs from resolved LLM-capable stage
|
||||
and validator bindings. It prepares a small internal check prompt for each ID so
|
||||
missing or invalid profiles fail before pipeline execution. The runtime profile
|
||||
override syntax and scope are defined in the
|
||||
[CLI reference](../cli.md#run); binding rules are defined in
|
||||
[Configuration](../config.md#module-bindings).
|
||||
|
||||
## OpenAI-Compatible Adapter
|
||||
## Scriptorium Adapter
|
||||
|
||||
`OpenAICompatibleClient` posts JSON to:
|
||||
`ScriptoriumClient` converts a Notarius request into a Scriptorium `RunRequest`.
|
||||
It validates the decoding target and prompt identity, maps named input materials
|
||||
to inline artifacts, forwards explicit profile and session context, delegates
|
||||
rendering/provider execution/structured validation, and unmarshals successful
|
||||
JSON into the caller target.
|
||||
|
||||
```text
|
||||
<base_url>/chat/completions
|
||||
```
|
||||
Empty optional input material is represented by a single space so Scriptorium
|
||||
retains the named input. The client returns Scriptorium's validated structured
|
||||
bytes rather than re-encoding the caller target, allowing modules to preserve
|
||||
the runtime result exactly.
|
||||
|
||||
It sends:
|
||||
Selected profile, provider, model, and token metadata are mapped into the
|
||||
Notarius response. The recorder deduplicates profiles by identity and supplies
|
||||
manifest-safe profile summaries after actual calls; manifest population does
|
||||
not guess the selected prompt default in advance.
|
||||
|
||||
- `model`
|
||||
- `messages`
|
||||
- `response_format.type = "json_schema"`
|
||||
- `response_format.json_schema.name`
|
||||
- `response_format.json_schema.strict = true`
|
||||
- `response_format.json_schema.schema`
|
||||
Generated-output validation failures and provider failures are wrapped with
|
||||
prompt context. Error strings pass through bearer-token redaction before they
|
||||
cross the runtime boundary.
|
||||
|
||||
If an API key is configured, the adapter sends an `Authorization: Bearer ...`
|
||||
header.
|
||||
## Scheduling
|
||||
|
||||
The adapter accepts assistant content either as a JSON string containing JSON or
|
||||
as raw JSON content. It then unmarshals that content into the caller-provided
|
||||
target.
|
||||
`Scheduler` uses a bounded permit count and a FIFO waiter queue. Immediate
|
||||
acquisition increments the in-flight count; queued acquisition waits for a
|
||||
permit or context cancellation. Cancellation removes a queued waiter, while a
|
||||
cancelled waiter that has already received a permit releases it.
|
||||
|
||||
External wire-contract details belong in the
|
||||
[OpenAI-compatible integration doc](../integrations/openai-compatible.md).
|
||||
`ScheduledClient` acquires a permit around each structured completion and
|
||||
defers release on every result path. The effective limit and default are
|
||||
configuration facts in [Configuration](../config.md#defaults).
|
||||
|
||||
## Retries And Timeouts
|
||||
This provider-call ceiling is independent of the pipeline's extract worker
|
||||
limit. Concurrent lanes, retries, and validators all use the same scheduled
|
||||
client, so increasing framework workers cannot exceed `total_llm`. Pipeline
|
||||
dispatch and cancellation mechanics are documented in
|
||||
[Pipeline Internals](pipeline.md#execution-flow).
|
||||
|
||||
The adapter retries:
|
||||
## Prompt And Schema Assets
|
||||
|
||||
- provider request failures;
|
||||
- response read failures;
|
||||
- HTTP `429`;
|
||||
- HTTP `5xx`;
|
||||
- malformed provider envelopes;
|
||||
- malformed assistant JSON;
|
||||
- structured-output decode failures.
|
||||
`AssetRegistry` combines caller-owned prompt filesystems under stable prefixes
|
||||
and rejects invalid or conflicting registrations. Production module packages
|
||||
register their own prompt and schema assets; generic framework code contains no
|
||||
D&D prompt content. `internal/framework/promptfs` provides the domain-neutral
|
||||
filesystem composition helper used to combine module-owned files with shared
|
||||
domain prompt fragments.
|
||||
|
||||
Non-retryable `4xx` responses are returned without retry. Request timeout comes
|
||||
from the effective LLM profile. Context cancellation is respected.
|
||||
The D&D scene chunker and spell, NPC, combat-turn, NPC-interaction, and
|
||||
scene-description extractors each declare an
|
||||
ordered prompt asset manifest. The manifest lists the package-owned YAML and
|
||||
Markdown files, then the exact shared fragments rendered by that prompt; the
|
||||
same ordered list drives both filesystem mounting and the prompt fingerprint.
|
||||
Unused shared assets are neither mounted nor fingerprinted. Universal
|
||||
extraction-evidence and output policy lives only in the shared extraction
|
||||
assets; package-owned prompt files retain artifact-specific rules. The scene
|
||||
prompt keeps its separate output rule because it does not render the
|
||||
extraction-evidence asset.
|
||||
|
||||
## Scheduler
|
||||
### D&D Extraction Prompt Ordering And Cache Boundaries
|
||||
|
||||
`Scheduler` bounds concurrent provider calls. It tracks in-flight calls and a
|
||||
FIFO queue of waiters. Cancellation removes queued waiters or releases granted
|
||||
permits.
|
||||
D&D extraction prompts order messages from the most reusable content to the
|
||||
most variable content. New extraction lanes use these tiers in order:
|
||||
|
||||
`NewScheduledClient` wraps any structured LLM client and runs each completion
|
||||
inside the scheduler.
|
||||
1. universal shared content, including the system, extraction-evidence, and
|
||||
in-world identity messages;
|
||||
2. stable campaign or run context shared across lanes, including campaign
|
||||
references;
|
||||
3. stable subset- and lane-specific context and instructions, including an NPC
|
||||
registry, catalog, task, or extraction instructions when applicable;
|
||||
4. the chunk transcript as the final user message.
|
||||
|
||||
Effective concurrency is:
|
||||
This ordering lets requests reuse the longest identical prefix before the
|
||||
per-chunk transcript changes. Cache reuse requires the preceding message
|
||||
sequence and content to be exactly identical; semantic similarity is not
|
||||
sufficient. Cache boundaries belong at the ends of reusable stable tiers,
|
||||
subject to the provider's cache-boundary limit. The shared identity and
|
||||
campaign-reference messages form the first two extraction boundaries. Spell,
|
||||
combat, and interaction prompts add a boundary at the shared NPC registry. Each extraction
|
||||
prompt places its final boundary on its lane-specific instructions, immediately
|
||||
before the transcript. The transcript does not carry cache control because no
|
||||
reusable content follows it.
|
||||
|
||||
1. `llm_profiles.<id>.max_concurrency`, when greater than zero;
|
||||
2. `concurrency.total_llm`, when greater than zero;
|
||||
3. `1`.
|
||||
Accordingly, the common prefix of the spell, NPC, combat, and interaction
|
||||
extraction prompts is system,
|
||||
extraction evidence, identity, and campaign references. The NPC prompt then
|
||||
renders task, instructions, and transcript. Spell renders the NPC registry,
|
||||
catalog, task, instructions, and transcript. Combat renders the NPC registry,
|
||||
task, instructions, and transcript. NPC interaction renders the names-only NPC
|
||||
registry, task, instructions, and transcript. The
|
||||
scene chunker is not an extraction lane: it retains its separate system,
|
||||
transcript, campaign-reference, task, and instruction order and marks its
|
||||
transcript and campaign-reference messages ephemeral.
|
||||
|
||||
## Schema Registry
|
||||
The scene-description extractor deliberately omits the citation-oriented
|
||||
`common-dnd-extraction-evidence.md` asset because Notarius attaches the whole
|
||||
accepted chunk range itself. Its manifest is system, shared identity, shared
|
||||
campaign references, lane task, lane instructions, then the transcript. The
|
||||
identity, campaign-reference, and instruction messages are ephemeral cache
|
||||
boundaries; the transcript is last and has no cache control. Compatible shared
|
||||
messages remain canonical shared assets rather than copied package text.
|
||||
|
||||
The framework schema registry embeds generic test schemas. It also exposes
|
||||
helpers for caller-owned schemas:
|
||||
Shared wording belongs in the canonical assets under
|
||||
`internal/modules/dnd/shared`; extraction packages reference those assets in
|
||||
their manifests instead of copying similar text into package-local files.
|
||||
Package-local assets contain only lane-specific content. An extraction lane may
|
||||
depart from the tier order only when prompt-quality evidence or a provider
|
||||
constraint makes the exception necessary; document the exception and rationale
|
||||
here when it becomes implemented behavior.
|
||||
|
||||
- `LoadResponseSchema`
|
||||
- `LookupResponseSchema`
|
||||
- `MustLookupResponseSchema`
|
||||
- `ResponseSchema.DiagnosticsMap`
|
||||
Schema helpers load embedded JSON Schema with identity and digest metadata,
|
||||
return defensive copies, and expose a diagnostics map that omits schema bytes.
|
||||
The small framework registry contains only generic test schemas; production
|
||||
schemas remain package-owned.
|
||||
|
||||
`DiagnosticsMap` omits raw schema content and includes metadata such as key,
|
||||
ID, version, name, and SHA-256.
|
||||
The spell, NPC, combat, NPC-interaction, and scene-description extractors'
|
||||
package-owned prompts declare their
|
||||
structured JSON inputs and private response schemas. Each private response
|
||||
schema remains separate from its durable artifact codec schema; this work does
|
||||
not use shared schema fragments or schema generation. Those private schemas own
|
||||
the transport envelope—required fields, JSON types, nullability, and
|
||||
unknown-field rejection—while deterministic validators own semantic constraints
|
||||
such as enum membership, non-empty values and collections, and positive
|
||||
numbers. The spell extractor's prompt declares a required
|
||||
`application/json` `spell_catalog` input and an optional `application/json`
|
||||
`npcs` input. The extractor generates
|
||||
the catalog input from its prepared
|
||||
effective catalog as `{"spell_names":[...]}` using sorted canonical names only.
|
||||
The shared D&D prompt assets include a generic NPC grounding fragment directly
|
||||
after the campaign reference message for spell, combat, and interaction prompts. When an NPC
|
||||
registry is bound, the
|
||||
domain registry boundary strictly decodes and identity-validates one durable
|
||||
artifact, re-encodes canonical JSON for provenance, and separately generates a
|
||||
names-only prompt projection. The unbound projection is exactly `{"npcs":[]}`.
|
||||
Prompt input and component-local checkpoint digests cover the projected bytes;
|
||||
manifests retain the optional full registry digest/count rather than names,
|
||||
overlay bytes, registry paths, or source metadata. Combat and interaction prompt,
|
||||
response-schema, mapping, normalization, identity, and registry-projection
|
||||
fingerprints remain separate semantic inputs to checkpoint identity.
|
||||
|
||||
The D&D spell extractor owns and loads its own embedded response schema.
|
||||
## Debug And Redaction Boundaries
|
||||
|
||||
## Secret Redaction
|
||||
The pipeline may wrap the client with a debug recorder that captures prepared
|
||||
prompt/response material for an explicitly requested debug run. Debug summaries
|
||||
and manifests receive identities, hashes, usage, and selected profile summaries
|
||||
rather than prompt, source, reference, schema, or response content.
|
||||
|
||||
Provider errors are passed through `ErrorWithSecretsRedacted` with the API key
|
||||
and bearer-token value. Config diagnostics use redacted effective config
|
||||
payloads.
|
||||
The Scriptorium error wrapper removes bearer credential values from surfaced
|
||||
provider errors; `RedactSecrets` and `ErrorWithSecretsRedacted` support known
|
||||
secret values elsewhere in the runtime. Config summaries use a separate
|
||||
clone-and-redact path in `internal/core/config`. These mechanisms implement the
|
||||
security invariant in
|
||||
[Architecture](../policy/architecture.md#state-output-and-safety); operator
|
||||
handling of debug data is defined in [Operations](../operations.md#debug).
|
||||
|
||||
Do not add raw provider request bodies, response bodies, API keys, or prompt
|
||||
payloads to diagnostics by default.
|
||||
## Failure Behavior
|
||||
|
||||
- Invalid targets, missing prompt IDs, malformed structured output, and
|
||||
Scriptorium failures return contextual errors to the calling module.
|
||||
- Scheduler construction rejects non-positive limits; acquisition respects
|
||||
context cancellation.
|
||||
- Asset registration rejects invalid roots, missing content, and path conflicts.
|
||||
- Schema loading distinguishes missing assets, invalid JSON, and invalid
|
||||
metadata.
|
||||
- Profile validation errors occur during CLI preparation when an explicit
|
||||
selected ID cannot be prepared.
|
||||
|
||||
## Tests To Inspect
|
||||
|
||||
- `internal/framework/llm/scriptorium_client_test.go`: adapter mapping and local
|
||||
HTTP integration.
|
||||
- `internal/framework/llm/scheduler_test.go` and
|
||||
`scheduled_client_test.go`: permits, FIFO behavior, cancellation, and wrapper
|
||||
release.
|
||||
- `internal/framework/llm/asset_registry_test.go` and
|
||||
`schema_registry_test.go`: asset composition, validation, and defensive
|
||||
copies.
|
||||
- `internal/framework/llm/secrets_test.go`: provider-error redaction.
|
||||
- `internal/cli/run_contract_test.go`: profile validation, production client
|
||||
wiring, manifest recording, and debug integration.
|
||||
- Module-local `scriptorium_assets_test.go` files: prompt inputs and package
|
||||
asset registration.
|
||||
|
||||
@@ -1,165 +1,630 @@
|
||||
# Modules
|
||||
# Module And Validator Internals
|
||||
|
||||
Production modules live under `internal/modules`. Each module implements one
|
||||
contract from `internal/framework/contracts`, exposes a `ModuleSpec`, and
|
||||
registers itself with the matching pipeline registry.
|
||||
Production module and validator implementations live under their domain-first
|
||||
trees in `internal/modules`.
|
||||
The selectable keys, configuration options, reference slots, and default
|
||||
validator chain are canonical in the
|
||||
[module](../config.md#implemented-production-modules) and
|
||||
[validator](../config.md#implemented-production-validators) catalogs in
|
||||
Configuration.
|
||||
|
||||
The CLI production catalog currently registers only the modules listed here.
|
||||
## Extension Pattern
|
||||
|
||||
## Contract Pattern
|
||||
A stage module package provides a stable key, constructor, contract
|
||||
implementation, `ModuleSpec`, `Register`, and focused behavior and registration
|
||||
tests. A validator package follows the same pattern with `ValidatorSpec` and the
|
||||
validator registry. Package-family registrars compose those leaf registrations
|
||||
into the production catalog and own family-level policy such as default
|
||||
validator chains and prompt asset collection.
|
||||
|
||||
A production module package should provide:
|
||||
Production input, chunk, output, and D&D spell-, NPC-, combat-, interaction-, and scene-description-extract packages
|
||||
register strict option decoders and run-local builders. Preparation decodes their options into
|
||||
implementation-owned values and injects dependencies plus the materialized
|
||||
reference set for the selected target. Each builder receives an isolated clone
|
||||
of that set; input and output builders receive no references. The spell, NPC,
|
||||
combat, interaction, and scene-description extractors are typed over the canonical D&D model. D&D validators, merge,
|
||||
and normalize use typed variants; JSON representation validators use serialized
|
||||
requests; and unconditional validators expose separate chunk and typed
|
||||
variants. The D&D production registrar registers the canonical typed spell,
|
||||
NPC, combat, interaction, and scene-description implementations, including their kind-specific merge and
|
||||
normalize behavior.
|
||||
|
||||
- a stable module key;
|
||||
- a constructor such as `New`;
|
||||
- the relevant contract implementation;
|
||||
- `ModuleSpec`;
|
||||
- `Register`;
|
||||
- focused tests for registration, options, contract behavior, and errors.
|
||||
For D&D artifact defaults, generic JSON syntax validation runs first. Rejecting
|
||||
domain validators then own semantic diagnostics before generic JSON Schema
|
||||
validation provides the final rejecting representation backstop; warning-only
|
||||
relatedness validators run last. This default composition does not reorder an
|
||||
explicitly configured validator chain.
|
||||
|
||||
Module specs should describe capabilities accurately. Resolution uses specs to
|
||||
reject incompatible pipelines before execution.
|
||||
Prepared extractors, extract validators, and codecs may be reused concurrently
|
||||
by the run-wide extract pool. Production implementations are immutable after
|
||||
construction: they retain only typed options, immutable assets, or the shared
|
||||
concurrency-safe LLM client. Implementations that introduce mutable state must
|
||||
synchronize that state without creating a separate provider scheduler.
|
||||
|
||||
## `seriatim` Input
|
||||
Specs expose capability and execution metadata without constructing an
|
||||
implementation. Registry entries separately expose option validation and
|
||||
run-local construction. Chunk, extract, merge, and normalize modules that accept
|
||||
auxiliary material declare identical reference slots from both
|
||||
`ReferenceSlots()` and `ModuleSpec().ReferenceSlots`; registration tests enforce
|
||||
that agreement. Runtime delivery uses the corresponding stage request's
|
||||
`References` field.
|
||||
|
||||
Package: `internal/modules/input/seriatim`
|
||||
LLM-backed extensions own their prompt definitions and response schemas under
|
||||
package-local embedded assets. Shared filesystem composition belongs in
|
||||
`internal/framework/promptfs`; reusable D&D prompt fragments, reference
|
||||
declarations, prompt-input assembly, and source-unit/citation helpers belong in
|
||||
`internal/modules/dnd/shared`, which owns operation-scoped indexed
|
||||
source-reference validation, citation traversal, ordering and canonicalization,
|
||||
plus bounded D&D diagnostics. The
|
||||
D&D scene chunker and spell, NPC, combat-turn, NPC-interaction, and scene-description extractors use ordered
|
||||
package-local prompt manifests for both rendering and prompt fingerprinting, so
|
||||
only the shared fragments each prompt actually renders participate in either
|
||||
operation. Extraction prompts place stable shared and lane-specific context
|
||||
before the variable transcript and use shared assets for wording common across
|
||||
lanes. The canonical ordering and cache-boundary policy is documented in
|
||||
[LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries). Stage
|
||||
contracts expose only Notarius structured-completion types, not Scriptorium
|
||||
public types.
|
||||
|
||||
The `seriatim` adapter parses Seriatim transcript JSON into a generic source
|
||||
document. It owns transcript JSON details, source ID selection, source digest
|
||||
creation, transcript segment validation, and segment metadata mapping.
|
||||
The shared `PrepareChunkExtraction` helper owns common extraction preflight and
|
||||
transcript material preparation for the spell, NPC, combat-turn,
|
||||
NPC-interaction, and scene-description extractors. It validates common request
|
||||
state, clones supplied source metadata, falls back to the materialized chunk
|
||||
when content is absent, checks that content remains chunk-identical, and fills
|
||||
only the common default fields. Extractors retain receiver, dependency, and
|
||||
lane-specific checks locally and wrap helper errors with their module context.
|
||||
|
||||
Provides:
|
||||
Reference material may inform a module or prompt but must not become source
|
||||
evidence. The resolver and materializer behavior is described in
|
||||
[Pipeline Internals](pipeline.md#reference-materialization).
|
||||
|
||||
- `source.transcript`
|
||||
- `transcript.speaker`
|
||||
- `transcript.timestamps`
|
||||
## Domain Reference Data
|
||||
|
||||
External JSON shape belongs in the Seriatim integration doc.
|
||||
### `internal/modules/dnd/spells/catalog`
|
||||
|
||||
## `generic` Chunker
|
||||
The spell catalog package owns the embedded, versioned D&D 5e 2014 SRD spell
|
||||
reference data. Its strict JSON asset contains one canonical record per spell,
|
||||
including spell level and all applicable class memberships. `LoadSRD5E2014`
|
||||
validates catalog identity, provenance metadata, ordering, uniqueness, levels,
|
||||
classes, aliases, and lookup-key collisions before exposing immutable copies.
|
||||
|
||||
Package: `internal/modules/chunk/generic`
|
||||
Lookup is case-insensitive and normalizes whitespace and common apostrophe
|
||||
variants while preserving source punctuation in canonical display names. The
|
||||
catalog contains 319 unique spells and 779 class memberships. Source and
|
||||
license details live beside the asset in `SOURCES.md`. This domain-owned data is
|
||||
separate from `internal/modules/dnd/shared`, which is reserved for reusable
|
||||
prompt and source-reference machinery.
|
||||
|
||||
The `generic` chunker splits source units into ordered chunks. It validates the
|
||||
source document, clones source units, assigns chunk IDs such as `chunk-000001`,
|
||||
and records chunk metadata for start unit, end unit, and unit count.
|
||||
`ResolveEffectiveCatalog` builds the immutable recognition view used by the
|
||||
spell extractor and catalog validator. It starts with the embedded SRD catalog
|
||||
and optionally applies one strict JSON overlay from the `spell_catalog` item in
|
||||
a materialized reference set. Overlay catalogs are ordered by ID, may add names
|
||||
and aliases, and may augment an existing canonical spell without replacing its
|
||||
display name. Cross-spell lookup collisions are errors. The effective view
|
||||
exposes sorted canonical names, normalized lookup, overlay identities, and a
|
||||
semantic digest; overlay content remains contextual reference material rather
|
||||
than source evidence. Its external JSON contract is defined in the
|
||||
[spell-catalog overlay contract](../integrations/dnd-spell-catalog-overlays.md).
|
||||
|
||||
Options:
|
||||
### `internal/modules/dnd/npcs/identity`, `internal/modules/dnd/npcs/registry`, and `internal/modules/dnd/codec/npcs`
|
||||
|
||||
- `max_units`: positive integer, default `50`;
|
||||
- `overlap_units`: non-negative integer, default `0`, and less than
|
||||
`max_units`.
|
||||
The NPC identity package owns Unicode comparison keys, deterministic
|
||||
`npc:sha256:` IDs, display normalization, and whole-registry collision issues.
|
||||
The registry package resolves one optional normalized artifact through the
|
||||
strict codec, validates whole-registry identity, canonicalizes its JSON, and
|
||||
provides immutable records, a names-only prompt projection, distinct durable
|
||||
and projection digests, count, and exact canonical-name lookup. External files cross this boundary during
|
||||
preparation; generated artifacts cross it at the ordered step handoff. It owns
|
||||
the `npcs` slot and its bounded, content-safe validation failures. NPC source
|
||||
references are durable provenance and are not treated as evidence for a
|
||||
consuming pipeline. The NPC codec owns the strict durable `dnd/npc-list` JSON
|
||||
boundary and exposes candidate versus approved encode/decode operations. The
|
||||
shared `internal/modules/dnd/codec/candidatejson` package supplies strict typed
|
||||
candidate JSON mechanics; each artifact codec retains its own durable schema
|
||||
and approved-value policy.
|
||||
|
||||
Provides:
|
||||
The `internal/modules/dnd/codec/combatturns` package owns the durable
|
||||
`dnd/combat-turn-list` schema and candidate versus approved JSON boundary. It
|
||||
is registered by the production D&D family registrar for the selectable combat
|
||||
lane.
|
||||
|
||||
- `chunks`
|
||||
The `internal/modules/dnd/codec/npcinteractions` package owns the durable
|
||||
`dnd/npc-interaction-list` schema and candidate versus approved JSON boundary.
|
||||
It is registered by the production D&D family registrar for the selectable
|
||||
interaction lane. Its external contract is documented in the
|
||||
[D&D NPC interaction artifact contract](../integrations/dnd-npc-interaction-artifacts.md).
|
||||
|
||||
## `dnd/spells` Extractor
|
||||
The `internal/modules/dnd/codec/scenedescriptions` package owns the durable
|
||||
`dnd/scene-description-list` schema and candidate versus approved JSON boundary.
|
||||
It is registered by the production D&D family registrar. Its external contract
|
||||
is documented in the
|
||||
[D&D scene-description artifact contract](../integrations/dnd-scene-description-artifacts.md).
|
||||
|
||||
Package: `internal/modules/extract/dnd/spells`
|
||||
## Input Adapter
|
||||
|
||||
The `dnd/spells` extractor owns D&D spell-cast artifact semantics. It renders
|
||||
embedded prompts, loads the embedded structured response schema, calls the
|
||||
structured LLM client, converts spell-cast responses into artifact candidates,
|
||||
and supplies deterministic validators.
|
||||
### `internal/modules/seriatim/input/transcript`
|
||||
|
||||
Requires:
|
||||
The adapter decodes the supported transcript JSON, selects the source identity,
|
||||
computes canonical source provenance, validates segments, and maps each segment
|
||||
into a generic source unit with a self-reference plus speaker and timestamp
|
||||
metadata. It accepts no module options. Its spec advertises the transcript
|
||||
capabilities consumed by D&D modules.
|
||||
|
||||
- `chunks`
|
||||
- `source.transcript`
|
||||
Parsing is strict about required values and duplicate unit IDs but deliberately
|
||||
ignores unrelated Seriatim fields. The external format and derived-identity
|
||||
rules are defined in the
|
||||
[Seriatim contract](../integrations/seriatim.md).
|
||||
|
||||
Provides:
|
||||
## Chunkers
|
||||
|
||||
- `dnd.spell_casts`
|
||||
Chunkers implement `contracts.Chunker.Plan`. A plan identifies ordered source
|
||||
unit ranges and may carry optional namespaced JSON annotations; it does not
|
||||
contain materialized chunk content. The framework canonicalizes annotations,
|
||||
validates ranges against the current source, and materializes chunk IDs,
|
||||
indexes, references, content, units, and generic metadata. Materialized source
|
||||
unit metadata is independently owned. Annotation
|
||||
namespaces remain optional data: generic framework code and downstream modules
|
||||
must not require D&D scene annotations or import `dnd/scenes`.
|
||||
|
||||
Artifact type and schema version:
|
||||
### `internal/modules/generic/chunk/units`
|
||||
|
||||
- artifact type: `dnd.spell_cast`
|
||||
- schema version: `v1`
|
||||
The generic chunker validates the source document and returns ranges over units
|
||||
in configured windows. Overlap changes the next window start but never reorders
|
||||
units. Framework materialization derives the resulting chunk identity and
|
||||
generic metadata from those ranges.
|
||||
|
||||
The extractor adds prompt and response-schema provenance to lane manifest
|
||||
metadata. Durable artifact payload details belong in the
|
||||
The accepted options and defaults are defined in
|
||||
[Configuration](../config.md#implemented-production-modules). Generic
|
||||
framework validation canonicalizes the returned unit slices before extraction.
|
||||
The chunker decodes its options during construction and retains only the typed
|
||||
window settings used by `Plan`.
|
||||
|
||||
### `internal/modules/dnd/chunk/scenes`
|
||||
|
||||
The scene chunker prepares a structured Scriptorium request from the full
|
||||
transcript, session, and optional D&D reference inputs. It validates the model's
|
||||
inclusive source-unit endpoints against document position and converts them
|
||||
into deterministic plan ranges. Preparation injects the shared structured LLM
|
||||
client into the chunker; `Plan` supplies only the run-specific profile, session,
|
||||
source, references, and metadata.
|
||||
|
||||
Scene validation requires sequential, contiguous, non-overlapping coverage from
|
||||
the first source unit through the last. Its private response contains only the
|
||||
boundary endpoints; the accepted plan has no D&D-specific annotations and
|
||||
produces no boundary warnings. Malformed structured output is returned as an
|
||||
error; there is no fallback chunker.
|
||||
|
||||
The package embeds its prompt and response schema and reports their non-secret
|
||||
identity and hashes through singleton module metadata. Shared D&D assets supply
|
||||
reference declarations and prompt inputs; their user-facing keys and accepted
|
||||
file types remain canonical in [Configuration](../config.md).
|
||||
|
||||
## Extractor
|
||||
|
||||
### `internal/modules/dnd/extract/spells`
|
||||
|
||||
The spell extractor prepares a structured request from one chunk, the
|
||||
chunk-scoped source input, the session, and optional D&D reference inputs. It
|
||||
decodes the model response, assigns the generic source identity to every source
|
||||
reference, canonicalizes duplicate references, orders spell casts by their
|
||||
earliest valid source-document position, and returns `dnd.SpellList`.
|
||||
|
||||
Its private response schema admits only the structural transport envelope:
|
||||
required fields, JSON types, array and object shapes, and unknown-field
|
||||
rejection. It maps integer source-unit candidates directly without repairing
|
||||
semantic values, so the deterministic shape, catalog, and source-reference
|
||||
validators own blank values, empty evidence, and invalid or unresolved ranges.
|
||||
|
||||
The extractor owns its private model-response DTO, embedded prompt, LLM response
|
||||
schema, strict option decoder, injected shared LLM client, and prompt/schema
|
||||
manifest metadata. During preparation it resolves the optional `spell_catalog`
|
||||
reference into an immutable effective catalog and adds a generated
|
||||
canonical-name-only JSON input to every structured completion request. Overlay
|
||||
failures therefore stop construction before source parsing or an LLM call;
|
||||
campaign references remain separate disambiguation inputs and never become
|
||||
source evidence.
|
||||
|
||||
The prompt includes only actual casting events and unambiguous declared casting
|
||||
attempts. Spell mentions, plans, rules discussion, and catalog matches without
|
||||
a casting event are excluded. Shared extraction-evidence and identity rules
|
||||
require transcript-supported caster and spell facts, while the catalog,
|
||||
campaign references, and NPC names only disambiguate source text. Structural
|
||||
source validation remains deterministic; semantic evidence sufficiency is
|
||||
enforced through extraction policy and evaluation.
|
||||
|
||||
Both the extractor and deterministic catalog validator expose
|
||||
the effective base-plus-overlay semantic digest as scoped prepared-component
|
||||
checkpoint identity. Raw overlay provenance independently covers file-byte
|
||||
changes, while the semantic digest also invalidates reuse when the embedded
|
||||
catalog or catalog composition changes. The extractor additionally fingerprints
|
||||
its complete prompt assets and private response schema, so either semantic
|
||||
contract changing invalidates previously recorded extraction checkpoints. The
|
||||
separate `internal/modules/dnd/codec/spells` package
|
||||
owns the durable schema and stable JSON representation for artifact kind
|
||||
`dnd/spell-list`. The runner keeps the result typed through validators and later
|
||||
stages, using the codec only for checkpoint, debug, and output boundaries.
|
||||
Shared D&D helpers keep prompt input names and source-unit reference conversion
|
||||
consistent with the scene chunker.
|
||||
|
||||
The extractor also declares the optional `npcs` registry slot and consumes the
|
||||
immutable registry boundary from `internal/modules/dnd/npcs/registry`. An
|
||||
external registry is prepared before execution; a generated registry is
|
||||
validated and supplied at operation time. Bound external registries add only
|
||||
the full `npc_registry_digest` and `npc_count` to module metadata. The local
|
||||
`npc_registry` checkpoint fingerprint always covers the names-only projection,
|
||||
including its exact unbound value. Generated bindings are represented by
|
||||
framework handoff provenance and dependency fingerprints. The unbound prompt
|
||||
input is exactly `{"npcs":[]}` and has no registry provenance.
|
||||
The shared NPC grounding fragment is placed immediately after the common
|
||||
campaign reference message and is included in the spell prompt fingerprint.
|
||||
|
||||
The durable payload and manifest metadata shapes are defined in the
|
||||
[D&D spell artifact contract](../integrations/dnd-spell-artifacts.md).
|
||||
|
||||
### `internal/modules/dnd/extract/npcs`
|
||||
|
||||
The NPC extractor maps private model output to the canonical `dnd.NPCList`,
|
||||
assigns source identity and deterministic NPC IDs, and preserves source
|
||||
references for deterministic validation. It uses the shared campaign
|
||||
references only for disambiguation and does not consume the optional NPC
|
||||
registry slot. Its prompt and private response schema are package-owned. The
|
||||
private response contains only a name and model-facing evidence ranges for each
|
||||
record; anonymous groups, generic roles, invented labels, descriptions,
|
||||
aliases, and relationships are outside its contract. The
|
||||
prompt follows the shared D&D extraction ordering and cache policy documented
|
||||
in [LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries).
|
||||
|
||||
The private response schema owns only structural transport validation and maps
|
||||
integer source-unit candidates unchanged. Required semantic content, non-empty
|
||||
evidence, and valid source ranges are rejected by the deterministic shape and
|
||||
source-reference validators.
|
||||
|
||||
### `internal/modules/dnd/extract/scenedescriptions`
|
||||
|
||||
The scene-description extractor makes one structured completion for each
|
||||
accepted chunk and maps its private `kind`, `title`, and `summary` response to
|
||||
one `dnd.SceneDescription`. It assigns the current chunk ID and exact range,
|
||||
preserves kind without repair, and trims only title and summary whitespace.
|
||||
Optional players, party, and glossary references can disambiguate prompt terms
|
||||
but do not supply evidence. The package owns its private schema, prompt assets,
|
||||
and mapping fingerprint; deterministic validators own the durable semantic
|
||||
checks. The durable contract is defined in the
|
||||
[D&D scene-description artifact contract](../integrations/dnd-scene-description-artifacts.md).
|
||||
|
||||
### `internal/modules/dnd/extract/combatturns`
|
||||
|
||||
The combat extractor prepares one structured request per supplied chunk using
|
||||
the shared extraction-evidence, identity, campaign-reference, NPC-grounding,
|
||||
and transcript prompt inputs. It
|
||||
maps the private response to `dnd.CombatTurnList`, assigns the current source
|
||||
identity, removes exact duplicate source ranges, and orders turns by valid
|
||||
source-document position while preserving malformed candidate fields for
|
||||
deterministic validators. Its package-owned private response schema enforces
|
||||
only the structural JSON envelope; semantic artifact constraints remain with
|
||||
the validator chain. Its prepared metadata and checkpoint fingerprints contain
|
||||
only prompt/schema/mapping identities plus an optional NPC registry digest.
|
||||
The prompt follows the shared D&D extraction ordering and cache policy
|
||||
documented in
|
||||
[LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries). The
|
||||
package exposes typed registration and is included in the production D&D
|
||||
registrar with the default combat extraction chain.
|
||||
|
||||
The combat normalizer accepts only the optional structured NPC registry.
|
||||
Campaign references remain extractor-only LLM context and are not materialized
|
||||
for deterministic normalization.
|
||||
|
||||
### `internal/modules/dnd/extract/npcinteractions`
|
||||
|
||||
The NPC interaction extractor requires the structured `npcs` registry slot. It
|
||||
uses the registry's names-only prompt projection with shared extraction
|
||||
evidence, identity, and transcript material, then maps private model records to
|
||||
`dnd.NPCInteractionList` with the current source identity. Registry source
|
||||
references are never reused as interaction evidence. The private response
|
||||
schema carries only name, bounded interaction kind, and source-unit ranges;
|
||||
deterministic validators own registry membership, source validity, and
|
||||
relatedness. Extract-stage source validation additionally requires every cited
|
||||
range to be wholly contained in the current materialized chunk. Prompt, schema,
|
||||
mapping, and the names-only registry projection
|
||||
participate in checkpoint identity, while generated producer identity remains
|
||||
framework provenance.
|
||||
|
||||
The shared D&D source-reference order defines canonical evidence ordering. The
|
||||
domain-owned `internal/modules/dnd/npcinteractions` package defines occurrence
|
||||
ordering, valid-evidence eligibility, and collision-safe exact identity. The
|
||||
interaction normalizer and normalized invariants validator consume those
|
||||
rules, so their production and checking paths cannot drift. Normalizer and
|
||||
relatedness warning lists use the shared D&D diagnostic cap and emit a final
|
||||
omission-summary warning when truncated.
|
||||
|
||||
### `internal/modules/dnd/normalize/npcs`
|
||||
|
||||
The NPC normalizer performs deterministic identity-aware consolidation in
|
||||
merged input order. It consolidates only equal canonical-name comparison keys,
|
||||
retains the first display record, and unions exact source references. It exposes
|
||||
the identity policy as its local checkpoint fingerprint and emits bounded
|
||||
normalization warnings.
|
||||
|
||||
## Merger And Normalizer
|
||||
|
||||
### `internal/modules/generic/merge/appendorder`
|
||||
|
||||
The merger passes typed values to an injected combine function in framework
|
||||
source-chunk order. The D&D registrar specializes it for all five artifact
|
||||
lists; each append merger preserves collection presence and order while giving
|
||||
the result independently owned nested source-reference slices.
|
||||
|
||||
### `internal/modules/generic/normalize/noop`
|
||||
|
||||
The normalizer returns the merged domain value unchanged and is reusable for
|
||||
any registered artifact type.
|
||||
|
||||
### `internal/modules/dnd/normalize/spells`
|
||||
|
||||
The typed spell normalizer resolves the optional `spell_catalog` reference into
|
||||
the same immutable SRD-plus-overlay effective catalog used by spell extraction
|
||||
and catalog validation. It performs no LLM calls. For each spell cast it
|
||||
canonicalizes recognized names using the catalog's case, whitespace,
|
||||
apostrophe, and alias rules; canonicalizes source references with the shared
|
||||
document-aware order; removes only exact reference duplicates; and emits
|
||||
bounded, scoped warnings for each mutation or unresolved name.
|
||||
|
||||
After those per-cast changes, it collapses only casts with the same canonical
|
||||
spell, case-folded and whitespace-normalized caster, and complete non-empty
|
||||
valid source-reference set. It retains the first occurrence and its caster,
|
||||
source references, and stable order. Unknown names, empty or invalid evidence,
|
||||
and adjacent or overlapping but different ranges remain unchanged for
|
||||
validation.
|
||||
|
||||
The normalizer exposes the effective catalog digest as its independently scoped
|
||||
`effective_catalog` checkpoint fingerprint and reports catalog base ID, digest,
|
||||
and overlay IDs as manifest metadata. Catalog contents, reference paths, and
|
||||
raw overlay bytes are not included in either surface. The normalize-stage
|
||||
reference is stage-local, so an overlay-capable pipeline binds the catalog
|
||||
independently for extraction and normalization.
|
||||
|
||||
### `internal/modules/dnd/normalize/combatturns`
|
||||
|
||||
The combat normalizer prepares an external NPC registry before execution or
|
||||
receives a generated registry at the ordered step handoff, then uses the
|
||||
immutable view during runtime. It display-normalizes actors,
|
||||
rewrites canonical-name matches for actors, orders and deduplicates source
|
||||
references, stable-sorts records by source-document position, and collapses
|
||||
only exact duplicate identities with fully valid evidence. It deep-clones
|
||||
output storage and emits bounded warnings scoped to merged input indexes. Its
|
||||
metadata and fingerprints identify the normalization and NPC identity policies.
|
||||
External bindings may contribute registry
|
||||
digest/count metadata; generated identity is retained in framework provenance
|
||||
and dependency fingerprints. The normalizer is included in the production D&D
|
||||
registrar with the default combat normalization chain.
|
||||
|
||||
### `internal/modules/dnd/normalize/npcinteractions`
|
||||
|
||||
The interaction normalizer requires the same immutable NPC registry. It
|
||||
canonicalizes exact registry-name matches, orders and de-duplicates source
|
||||
references, stable-sorts occurrences by source-document position, and collapses
|
||||
only exact interaction identities with valid evidence. It does not infer,
|
||||
merge, or summarize distinct occurrences. Its metadata and fingerprints expose
|
||||
the normalization and NPC identity policies; generated registry identity stays
|
||||
in framework provenance and checkpoint dependencies.
|
||||
|
||||
### `internal/modules/dnd/normalize/scenedescriptions`
|
||||
|
||||
The scene-description normalizer has no options or references. It validates
|
||||
each source range against the source document, trims title and summary
|
||||
whitespace, orders records by source position then ID, removes only exactly
|
||||
identical records, and rejects conflicting reused IDs or ranges. Its policy
|
||||
fingerprint identifies this deterministic behavior; the matching invariant
|
||||
validator checks the normalized result in the production chain.
|
||||
|
||||
## Output Encoder
|
||||
|
||||
### `internal/modules/generic/output/json`
|
||||
|
||||
The JSON encoder sorts normalized results by lane, derives collision-checked
|
||||
safe logical names, pretty-prints JSON payloads, and assembles the logical index,
|
||||
manifest, rejected-result, warning, and lane files. Invalid JSON, unsupported
|
||||
media types, unsafe names, and sanitized-name collisions are errors.
|
||||
|
||||
Its strict `include_chunk_map` option is disabled by default. When enabled, it
|
||||
validates the framework-supplied accepted chunk map through its codec and adds
|
||||
the pipeline-wide `chunk-map.json` plus its index descriptor; it does not treat
|
||||
the map as a lane payload. The external shape is owned by the
|
||||
[Accepted Chunk Map contract](../integrations/chunk-map.md).
|
||||
|
||||
The encoder returns logical files only. The CLI places them on disk, and the
|
||||
[JSON output contract](../integrations/json-output.md) defines their external
|
||||
paths and schemas.
|
||||
|
||||
## Generic Validators
|
||||
|
||||
The generic validator implementations live under
|
||||
`internal/modules/generic/validate`.
|
||||
|
||||
The unconditional accept and reject validators provide explicit chunk and
|
||||
typed-artifact variants used primarily for controlled composition and tests.
|
||||
|
||||
The serialized JSON syntax validator uses `encoding/json` to reject malformed
|
||||
representation bytes. The serialized JSON Schema validator requires schema
|
||||
bytes, parses the instance and schema with `jsonschema`, and distinguishes
|
||||
payload rejection from schema loading or compilation errors. The framework
|
||||
serialized-validation request carries either canonical chunk bytes or artifact
|
||||
codec bytes according to its target context. Neither validator calls the LLM.
|
||||
|
||||
## D&D Spell Validators
|
||||
|
||||
The spell extractor returns two built-in validators:
|
||||
All four validators receive `dnd.SpellList` directly. The shape validator
|
||||
rejects a missing list, blank caster or spell names, and empty reference lists.
|
||||
The catalog validator defers when shape is invalid, then checks every non-empty
|
||||
spell name against the immutable effective SRD and overlay catalog. It accepts
|
||||
normalized canonical names and aliases without rewriting the artifact; unknown
|
||||
names reject the complete result with bounded, stable index/name diagnostics. The
|
||||
source-reference validator defers malformed shapes, validates every cited
|
||||
range, and reports all range defects through a bounded aggregate while
|
||||
preserving `invalid_source_refs`. The relatedness validator resolves all cited
|
||||
ranges through the shared document-order traversal, then warns when a normalized
|
||||
consecutive spell-name token sequence is absent from the cited source text.
|
||||
Invalid shape
|
||||
or cited ranges produce no relatedness warnings; the shape and source-reference
|
||||
validators own those defects.
|
||||
|
||||
- `dnd/spells/shape`: rejects malformed payloads and missing required fields.
|
||||
- `dnd/spells/source_refs`: rejects candidates without valid source references.
|
||||
These validators are deterministic. Shape, source-reference, and relatedness
|
||||
each expose a local semantic `policy` checkpoint fingerprint. The catalog
|
||||
validator instead exposes its effective catalog digest as its semantic
|
||||
checkpoint identity and does not add a separate policy fingerprint. Their
|
||||
selectable keys and production order are defined in
|
||||
[Configuration](../config.md#implemented-production-validators); their durable
|
||||
payload rules are defined in the
|
||||
[artifact contract](../integrations/dnd-spell-artifacts.md).
|
||||
|
||||
Reason codes include:
|
||||
## D&D NPC Validators
|
||||
|
||||
- `invalid_payload`
|
||||
- `missing_required_field`
|
||||
- `missing_source_ref`
|
||||
- `invalid_source_ref`
|
||||
NPC shape validation checks the required ID and name strings, list presence, and source-reference
|
||||
shape. The source-reference validator defers malformed shapes, checks
|
||||
current-document identity, unit existence, and range ordering, and reports all
|
||||
defects through bounded aggregates. Source relatedness uses the shared
|
||||
document-order traversal and normalized consecutive-token matching, emitting at
|
||||
most one bounded warning per record when the canonical name does not occur near
|
||||
its cited text. Invalid shape or cited ranges produce no relatedness warnings.
|
||||
Normalize identity validation checks deterministic IDs, canonical names, and
|
||||
duplicate canonical-name or ID ownership.
|
||||
All are deterministic and expose the policy fingerprints used by the
|
||||
production chains.
|
||||
|
||||
These validators are supplied by the extractor when no validators are configured
|
||||
for the lane.
|
||||
## D&D Combat Validators
|
||||
|
||||
## `appendorder` Merger
|
||||
Combat shape validation owns the required list, actor, supported turn kind, and
|
||||
non-empty source-reference collection. Combat source-reference validation defers invalid
|
||||
shape, checks source identity, unit existence, and range order, and reports all
|
||||
defects through bounded aggregates. Combat source-relatedness defers invalid
|
||||
shape or ranges, uses the shared traversal to combine overlapping cited units
|
||||
in document order, and emits at most one bounded advisory warning per turn for
|
||||
an unrelated actor. Actors use normalized consecutive-token matching. The
|
||||
normalized-invariants validator owns actor display normalization, canonical
|
||||
source-reference order, chronology, and exact duplicate identity; it defers
|
||||
shape and source-reference failures. All four validators are deterministic and
|
||||
expose local policy fingerprints. In the registered defaults, JSON syntax runs
|
||||
first; combat shape, normalized invariants when applicable, and source-reference
|
||||
validation precede JSON Schema validation; warning-only relatedness runs last.
|
||||
|
||||
Package: `internal/modules/merge/appendorder`
|
||||
## D&D NPC Interaction Validators
|
||||
|
||||
The `appendorder` merger clones and appends candidates in chunk order. It does
|
||||
not deduplicate or reconcile candidates.
|
||||
Interaction shape validation owns the required list, registry name, supported
|
||||
kind, and non-empty source-reference collection. Registry validation checks
|
||||
exact membership in the required immutable NPC registry. Source-reference and
|
||||
relatedness validation use the current transcript only; malformed candidates
|
||||
are deferred by later validators and produce no relatedness warning. The
|
||||
normalized-invariants validator owns canonical registry names, source-reference
|
||||
order, chronology, and exact duplicate identity. The production chains run
|
||||
shape, registry, and source-reference checks before JSON Schema validation;
|
||||
relatedness remains warning-only and last.
|
||||
|
||||
Provides:
|
||||
## D&D Scene Description Validators
|
||||
|
||||
- `merged`
|
||||
|
||||
## `noop` Normalizer
|
||||
|
||||
Package: `internal/modules/normalize/noop`
|
||||
|
||||
The `noop` normalizer clones merged candidates and returns them unchanged.
|
||||
|
||||
Requires:
|
||||
|
||||
- `merged`
|
||||
|
||||
Provides:
|
||||
|
||||
- `normalized`
|
||||
|
||||
## `json` Output
|
||||
|
||||
Package: `internal/modules/output/json`
|
||||
|
||||
The `json` output encoder converts approved artifacts, rejected artifacts,
|
||||
warnings, and the run manifest into logical JSON output files. It groups
|
||||
approved artifacts by artifact type and sanitizes artifact-type file names.
|
||||
|
||||
Requires:
|
||||
|
||||
- `normalized`
|
||||
|
||||
Provides:
|
||||
|
||||
- `encoded`
|
||||
|
||||
Durable output file shapes belong in the
|
||||
[JSON output contract](../integrations/json-output.md). Operator behavior
|
||||
belongs in [Operations](../operations.md).
|
||||
Scene-description shape validation owns the non-empty list, trimmed ID and
|
||||
prose, closed kind, and basic source-reference shape. Extract-stage source
|
||||
validation additionally requires the one record to attach exactly to the
|
||||
current accepted chunk; later source validation checks source membership.
|
||||
Relatedness checks the title and summary independently against only their cited
|
||||
transcript range and emits bounded advisory warnings. The normalized-invariants
|
||||
validator owns ordering, exact duplicate elimination, and conflicting ID or
|
||||
range detection. The production chains run shape and source-reference checks
|
||||
before JSON Schema validation; the warning-only relatedness check is last.
|
||||
|
||||
## Production Registration
|
||||
|
||||
Production registration is centralized in `internal/cli/catalog.go`.
|
||||
Production composition occurs through family registrars. The CLI allocates one
|
||||
complete framework registry set and one LLM asset registry. It invokes
|
||||
`internal/modules/generic/register`,
|
||||
`internal/modules/seriatim/register`, and `internal/modules/dnd/register` in
|
||||
that order, then exposes the matching catalog for resolution. The generic and
|
||||
Seriatim registrars own their production leaf registrations. The D&D registrar
|
||||
owns D&D leaf registrations, typed spell, NPC, combat, interaction, and scene-description default-validator
|
||||
chains, typed append-order specializations, and D&D prompt/schema asset
|
||||
collection. Its registration helpers group module, validator, prompt-asset, and
|
||||
chain composition while retaining artifact-specific merge and clone behavior in
|
||||
the registrar.
|
||||
|
||||
Do not make framework code import production modules. The CLI wires production
|
||||
modules at the application boundary; tests may provide fake registries or fake
|
||||
catalogs directly.
|
||||
Concrete implementation packages do not import generic implementation
|
||||
packages directly. A concrete family's `register` package is its composition
|
||||
point for specializing reusable generic implementations, while the generic
|
||||
registrar composes only generic children.
|
||||
|
||||
## Adding A Module
|
||||
Core and framework production packages do not import production extensions.
|
||||
CLI production code is the sole application composition root for extensions
|
||||
and imports only exact family registrar packages. Other production packages,
|
||||
including commands and newly introduced package trees, do not import module
|
||||
packages directly. Compatibility tests in the CLI, core, and framework trees
|
||||
may import roots and implementation leaves directly. Other non-module tests do
|
||||
not receive that exemption. White-box tests within module families retain the
|
||||
production family boundaries. `internal/modules/integration` is test
|
||||
infrastructure: its black-box tests may compose multiple families, but it is
|
||||
not a production module family or production dependency target.
|
||||
|
||||
When adding a module, keep source-format and extraction-domain boundaries clear:
|
||||
## Adding An Extension
|
||||
|
||||
- input modules may know external source formats;
|
||||
- extract modules may know artifact semantics and prompt/schema assets;
|
||||
- merge and normalize modules own candidate combination and reconciliation;
|
||||
- output modules own serialization, not diagnostics or CLI reporting.
|
||||
When adding a production module or validator:
|
||||
|
||||
Update [Development](../policy/development.md), [Configuration](../config.md),
|
||||
internal docs, integration docs, and examples when the new module becomes
|
||||
implemented production behavior.
|
||||
1. implement the stage or validator contract and package-local key;
|
||||
2. expose and test its spec, constructor, and registration function;
|
||||
3. keep format or domain parsing inside the concrete package;
|
||||
4. add package-owned prompt/schema assets when the extension is LLM-backed;
|
||||
new LLM-backed D&D extraction modules must follow the stable-to-variable
|
||||
prompt ordering, shared-asset ownership, and cache-boundary policy in
|
||||
[LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries), or
|
||||
document the implemented exception and its evidence there;
|
||||
5. register it through its package-family registrar and add a default chain
|
||||
there only when production policy requires one;
|
||||
6. add resolution and composition coverage for capabilities, options,
|
||||
references, and validation behavior;
|
||||
7. update the selectable-key catalog in [Configuration](../config.md), the
|
||||
relevant external contract, this inventory, and maintained examples when
|
||||
user-visible behavior changes.
|
||||
|
||||
Do not add the extension to `docs/development.md`; that file routes by task and
|
||||
does not inventory implementations.
|
||||
|
||||
### D&D Extractor Contract
|
||||
|
||||
New D&D extractors preserve these package-owned responsibilities:
|
||||
|
||||
- Reject unknown options unless an option namespace is intentionally
|
||||
extensible, and use shared common preflight while retaining receiver,
|
||||
dependency, and lane-specific checks locally.
|
||||
- Return independently owned results and exposed metadata that callers may
|
||||
safely mutate.
|
||||
- Keep the private response DTO, structural response schema and its identity,
|
||||
provider-response mapping, durable artifact conversion, and lane diagnostics
|
||||
in the owning package.
|
||||
- Include every stable semantic input that can change durable output in
|
||||
checkpoint identity. Consider prompt, schema, mapping, canonicalization,
|
||||
prepared reference projections, identity, normalization, and trimming where
|
||||
applicable.
|
||||
- Add focused behavioral coverage where the lane's risks warrant it, including
|
||||
construction and registration, option rejection, preflight, provider
|
||||
failures, structured decoding, mapping and ownership, prompt
|
||||
role/input/cache order, and checkpoint invalidation.
|
||||
|
||||
Prompt ordering, shared-asset ownership, cache boundaries, and private-schema
|
||||
rules are defined in [LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries).
|
||||
[Pipeline Internals](pipeline.md#reference-materialization) owns reference
|
||||
materialization, and its [checkpoint hooks](pipeline.md#checkpoint-and-debug-hooks)
|
||||
define checkpoint behavior. Follow [Architecture](../policy/architecture.md#source-and-domain-boundaries)
|
||||
for ownership boundaries and the [Testing Policy](../policy/testing.md) when
|
||||
selecting durable coverage. This contract intentionally does not prescribe
|
||||
prompt prose or length, hashes, test counts, filenames, fixture layouts, or
|
||||
generic implementation builders.
|
||||
|
||||
## Tests To Inspect
|
||||
|
||||
- Package-local `*_test.go` files under the module or validator being changed.
|
||||
- `internal/framework/pipeline/typed_resolution_test.go`: typed registry, spec,
|
||||
and heterogeneous artifact composition.
|
||||
- `internal/framework/pipeline/profile_test.go`: framework binding defaults and
|
||||
profile resolution.
|
||||
- `internal/cli/production_contract_test.go`: production catalog, config
|
||||
resolution, and composition smoke coverage.
|
||||
- `internal/cli/example_contract_test.go`: maintained example ownership.
|
||||
- `internal/framework/promptfs/*_test.go` and
|
||||
`internal/modules/dnd/shared/*_test.go`: shared prompt and reference assembly.
|
||||
- `internal/modules/integration/*_test.go`: black-box composition across
|
||||
production extension domains.
|
||||
|
||||
@@ -1,86 +1,186 @@
|
||||
# Internal Overview
|
||||
|
||||
This directory documents implemented Notarius internals for developers and LLM
|
||||
coding agents. It complements [Architecture](../policy/architecture.md), which
|
||||
is the durable policy for boundaries and invariants.
|
||||
This document inventories the implemented Notarius components. Normative
|
||||
boundaries and dependency direction belong in
|
||||
[Architecture](../policy/architecture.md); external behavior belongs in the
|
||||
[CLI](../cli.md), [Configuration](../config.md),
|
||||
[Operations](../operations.md), and [integration contracts](../integrations/).
|
||||
|
||||
## Executable And CLI
|
||||
## Execution Path
|
||||
|
||||
`cmd/notarius` calls the CLI package. `internal/cli` owns:
|
||||
`cmd/notarius` delegates to `internal/cli`, the production composition root.
|
||||
The CLI loads configuration, builds the production catalogs and runtime
|
||||
collaborators, invokes `internal/framework/pipeline`, and places the logical
|
||||
output files returned by the runner. Cache and debug collaborators are supplied
|
||||
at this boundary.
|
||||
|
||||
- command parsing and usage;
|
||||
- config discovery and loading;
|
||||
- production module catalog and registry wiring;
|
||||
- production LLM client construction;
|
||||
- run directory creation;
|
||||
- durable output writes;
|
||||
- user-facing stdout, stderr, and exit codes.
|
||||
Resolution produces a fixed ordered workflow of steps and globally unique,
|
||||
sorted artifact lanes. Preparation constructs the complete module and validator
|
||||
set before the runner receives source bytes. Source parsing and chunking are
|
||||
serial. Each step then uses a bounded run-wide extraction pool followed by
|
||||
serial per-lane merge and normalize continuations. A step barrier prevents
|
||||
later consumers from starting until all earlier lanes are terminal and their
|
||||
required normalized artifacts have crossed the typed handoff.
|
||||
|
||||
The CLI should stay thin around framework contracts. Domain extraction behavior
|
||||
belongs in modules, not in command handlers.
|
||||
## Application Boundary
|
||||
|
||||
| Package | Implemented responsibility |
|
||||
| --- | --- |
|
||||
| `cmd/notarius` | Executable entry point and process exit delegation. |
|
||||
| `internal/cli` | Command parsing, config discovery, package-family registrar invocation, LLM client construction, reference materialization, state collaborator setup, durable writes, and user-facing results. |
|
||||
|
||||
## Core Packages
|
||||
|
||||
- `internal/core/artifacts`: artifact candidates, approved artifacts, rejected
|
||||
artifacts, validation decisions, and run manifests.
|
||||
- `internal/core/config`: defaults, YAML config parsing, environment overrides,
|
||||
validation, redaction, and resolved pipeline config.
|
||||
- `internal/core/diagnostics`: per-run diagnostics directory creation,
|
||||
diagnostics artifact writers, atomic writes, and retention decisions.
|
||||
- `internal/core/source`: source documents, source units, source references, and
|
||||
validation.
|
||||
|
||||
Core packages should remain deterministic and concrete. They should not import
|
||||
production modules.
|
||||
| Package | Implemented responsibility |
|
||||
| --- | --- |
|
||||
| `internal/core/artifacts` | Run-manifest and provenance models. |
|
||||
| `internal/core/config` | Defaults, YAML parsing, environment overrides, validation, redaction, and effective pipeline resolution. |
|
||||
| `internal/core/debugbundle` | Explicit per-run debug-bundle allocation and redacted summary writing. |
|
||||
| `internal/core/fileio` | Generic confined atomic file and JSON writes with caller-selected permissions. |
|
||||
| `internal/core/source` | Generic source documents, units, chunks, canonical references, validation, deterministic source digests, and independent metadata materialization. |
|
||||
|
||||
## Framework Packages
|
||||
|
||||
- `internal/framework/contracts`: interfaces and request/result structs for
|
||||
input adapters, chunkers, extractors, mergers, normalizers, validators, output
|
||||
encoders, and structured LLM clients.
|
||||
- `internal/framework/pipeline`: module registries, module specs, profile
|
||||
resolution, capability checks, run orchestration, warnings, validation, and
|
||||
manifest population.
|
||||
- `internal/framework/llm`: OpenAI-compatible structured-output client,
|
||||
scheduler, schema registry, retries, and secret redaction.
|
||||
- `internal/framework/prompt`: embedded prompt registry and template rendering.
|
||||
- `internal/framework/validate`: validator decision helpers and cardinality
|
||||
enforcement.
|
||||
| Package | Implemented responsibility |
|
||||
| --- | --- |
|
||||
| `internal/framework/contracts` | Source-stage contracts plus artifact identity, schema, serialized representation, codec, validator, reference, output, and structured-completion interfaces and data types. |
|
||||
| `internal/framework/pipeline` | Module and artifact-codec registries, ordered-step and generated-reference resolution, option validation, profile resolution, capability checks, external reference materialization, complete pipeline preparation, typed handoff, retries, orchestration, warnings, checkpoint decisions, and manifest population. |
|
||||
| `internal/framework/validate` | Shared validator decision and cardinality helpers. |
|
||||
| `internal/framework/llm` | Scriptorium-backed structured completions, prompt/schema registration, scheduling, profile recording, and secret redaction. |
|
||||
| `internal/framework/promptfs` | Builds module prompt filesystems from module-owned and caller-provided shared prompt assets. |
|
||||
| `internal/framework/checkpoint` | Root-based checkpoint loading, recording, identity, and payload serialization. |
|
||||
| `internal/framework/chunkplan` | Source-addressed chunk-plan filesystem storage, envelope validation, and atomic publication. |
|
||||
| `internal/framework/chunkmap` | Strict durable accepted chunk-map construction, schema, validation, cloning, and serialization. |
|
||||
| `internal/framework/debug` | Root-based framework and LLM debug recording. |
|
||||
|
||||
Framework code should stay source-agnostic and domain-agnostic.
|
||||
Framework contracts provide typed artifact, provenance-wrapper, chunk-validator,
|
||||
serialized-validator, and
|
||||
typed-validator interfaces. The runner owns handoff provenance, validation
|
||||
sequencing, rejection handling, checkpoint and debug boundaries, and final
|
||||
manifest assembly.
|
||||
|
||||
## Module Packages
|
||||
Artifact registries support heterogeneous typed extraction entries and
|
||||
kind-specific merger, normalizer, and validator variants. Resolution derives a
|
||||
lane's kind from its extractor, requires the matching codec, verifies exact Go
|
||||
type equality across the lane, and records schema identity in the resolved lane
|
||||
and pipeline digest. Registry entries carry separate option-validation and
|
||||
run-local construction closures. Preparation injects shared dependencies and
|
||||
constructs input, chunk, validators, ordered lanes, and output before source
|
||||
parsing. Production modules use strict construction-time option decoding, and
|
||||
LLM-backed modules retain the injected shared client. The D&D family registers
|
||||
the canonical `dnd/spell-list`, `dnd/npc-list`, `dnd/combat-turn-list`,
|
||||
`dnd/npc-interaction-list`, and `dnd/scene-description-list` codecs, typed
|
||||
spell, NPC, combat, interaction, and scene-description extractors and
|
||||
normalizers, validators,
|
||||
plus kind-specific generic merge strategies; generic JSON validators use the
|
||||
serialized-validation contract. The runner executes lanes through
|
||||
private exact-type-checked closures, coordinates extract results independently
|
||||
of completion timing, and serializes artifacts only through their codec at
|
||||
checkpoint, debug, and output boundaries.
|
||||
|
||||
Production module packages live under `internal/modules`:
|
||||
## Production Extensions
|
||||
|
||||
- `input/seriatim`
|
||||
- `chunk/generic`
|
||||
- `extract/dnd/spells`
|
||||
- `merge/appendorder`
|
||||
- `normalize/noop`
|
||||
- `output/json`
|
||||
The canonical catalogs of user-selectable
|
||||
[module](../config.md#implemented-production-modules) and
|
||||
[validator](../config.md#implemented-production-validators) keys are in
|
||||
Configuration. The implemented module packages are:
|
||||
|
||||
Each module package owns its contract implementation, module spec,
|
||||
registration, options, focused tests, and module-specific errors.
|
||||
| Package | Implemented responsibility |
|
||||
| --- | --- |
|
||||
| `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. |
|
||||
| `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. |
|
||||
| `internal/modules/dnd/chunk/scenes` | Produces contiguous D&D scene chunks from structured model output. |
|
||||
| `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, combat-turn-list, combat-turn, NPC-interaction-list, and scene-description-list artifact types. |
|
||||
| `internal/modules/dnd/codec/spells` | Strictly decodes and stably encodes the durable D&D spell-list representation. |
|
||||
| `internal/modules/dnd/codec/npcs` | Strictly decodes and stably encodes the durable D&D NPC-list representation. |
|
||||
| `internal/modules/dnd/codec/combatturns` | Strictly decodes and stably encodes the durable D&D combat-turn-list representation. |
|
||||
| `internal/modules/dnd/codec/npcinteractions` | Strictly decodes and stably encodes the durable D&D NPC-interaction-list representation. |
|
||||
| `internal/modules/dnd/codec/scenedescriptions` | Strictly decodes and stably encodes the durable D&D scene-description-list representation. |
|
||||
| `internal/modules/dnd/extract/spells` | Maps private structured model output to canonical source-grounded D&D spell lists. |
|
||||
| `internal/modules/dnd/extract/npcs` | Maps private structured model output to canonical source-grounded D&D NPC lists. |
|
||||
| `internal/modules/dnd/extract/combatturns` | Maps private structured model output to source-grounded D&D combat-turn candidates and preserves chronology and invalid candidate values for validators. |
|
||||
| `internal/modules/dnd/extract/npcinteractions` | Maps private structured model output to current-source NPC interaction candidates grounded by a required registry. |
|
||||
| `internal/modules/dnd/extract/scenedescriptions` | Maps one private scene description to the current accepted chunk's ID and exact range. |
|
||||
| `internal/modules/dnd/npcinteractions` | Owns interaction occurrence ordering, valid-evidence checks, and exact interaction identity shared by normalization and invariant validation. |
|
||||
| `internal/modules/dnd/normalize/combatturns` | Canonicalizes and orders merged combat turns, applies exact NPC identity matches, and collapses only exact valid-evidence duplicates. |
|
||||
| `internal/modules/dnd/normalize/npcinteractions` | Canonicalizes required-registry names, orders interaction occurrences, and collapses only exact valid-evidence duplicates. |
|
||||
| `internal/modules/dnd/normalize/scenedescriptions` | Trims, source-orders, and removes only exactly identical scene descriptions while rejecting ID and range conflicts. |
|
||||
| `internal/modules/dnd/validate/combatturns` | Provides deterministic shape, source-reference, source-relatedness, and normalized-invariant validation for the production combat chains. |
|
||||
| `internal/modules/dnd/validate/npcinteractions` | Provides deterministic shape, registry, source-reference, source-relatedness, and normalized-invariant validation for interaction chains. |
|
||||
| `internal/modules/dnd/validate/scenedescriptions` | Provides deterministic shape, exact extraction attachment, source-relatedness, and normalized-invariant validation for scene-description chains. |
|
||||
| `internal/modules/dnd/npcs/registry` | Resolves validated normalized NPC references into immutable grounding data and exact identity lookup. |
|
||||
| `internal/modules/dnd/npcs/identity` | Owns Unicode-aware NPC identity, ID derivation, and registry collision validation. |
|
||||
| `internal/modules/dnd/spells/catalog` | Embeds and validates the versioned D&D 5e 2014 SRD catalog, composes optional overlays, and provides immutable effective lookup. |
|
||||
| `internal/modules/generic/merge/appendorder` | Combines accepted extraction results in chunk order. |
|
||||
| `internal/modules/generic/normalize/noop` | Preserves accepted merged output. |
|
||||
| `internal/modules/dnd/normalize/spells` | Canonicalizes catalog-backed spell names and exact source references, conservatively collapses duplicate casts, and reports deterministic warnings and independently scoped catalog checkpoint identity. |
|
||||
| `internal/modules/dnd/normalize/npcs` | Consolidates NPC records deterministically by canonical name, unions exact evidence, and reports bounded warnings. |
|
||||
| `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, rejections, and an explicitly enabled accepted chunk map as logical JSON files. |
|
||||
|
||||
## Fixtures And Tests
|
||||
`internal/modules/dnd/shared` owns reusable D&D prompt fragments,
|
||||
reference declarations, prompt input assembly, document-aware source-reference
|
||||
ordering and canonicalization, and bounded diagnostics under
|
||||
`internal/modules/dnd/shared/diagnostics`.
|
||||
The shared NPC grounding fragment is mounted for D&D prompts and is owned by
|
||||
this package. Domain-neutral prompt filesystem composition lives in
|
||||
`internal/framework/promptfs`.
|
||||
|
||||
The repository uses focused package tests plus a fixture-driven CLI workflow.
|
||||
The `dnd/npcs/registry` package owns the optional `npcs` registry boundary.
|
||||
External references are strictly decoded and identity-validated during
|
||||
preparation; generated references are decoded and identity-validated at the
|
||||
ordered step handoff. Both paths retain canonical registry JSON for provenance
|
||||
and emit a names-only projection to operation-time spell, combat, and
|
||||
interaction prompts. Combat and interaction normalization use the canonical
|
||||
registry for exact name lookup. The
|
||||
framework records generated identity and bounded producer provenance, while
|
||||
the raw external reference remains independently tracked by pipeline
|
||||
provenance. An absent registry is represented only by the empty prompt value
|
||||
`{"npcs":[]}`. Spell
|
||||
and combat consumers use this shared boundary without changing their public
|
||||
module contracts. Interaction consumers require it and retain only current
|
||||
transcript references as durable evidence.
|
||||
|
||||
- CLI acceptance tests cover maintained examples under `examples/`.
|
||||
- Pipeline tests cover registry composition and end-to-end framework behavior
|
||||
with fakes.
|
||||
- Module tests cover implemented module contracts without requiring real
|
||||
provider calls.
|
||||
- LLM tests use local test servers and fakes.
|
||||
Generic validators under `internal/modules/generic/validate` provide
|
||||
unconditional test decisions, JSON syntax validation, and JSON Schema
|
||||
validation. D&D spell validators under `internal/modules/dnd/validate/spells`
|
||||
consume the canonical spell-list type directly to provide shape,
|
||||
effective-catalog, source-reference, and source-relatedness decisions.
|
||||
|
||||
Do not use real external services in tests. Use fakes, fixtures, or local test
|
||||
servers.
|
||||
Production composition is grouped behind package-family registrars, and every
|
||||
implemented production extension uses its domain-first tree:
|
||||
|
||||
## Boundary Reminders
|
||||
| Package | Implemented responsibility |
|
||||
| --- | --- |
|
||||
| `internal/modules/generic/register` | Registers domain-neutral chunk, merge, normalize, output, and validator implementations. |
|
||||
| `internal/modules/seriatim/register` | Registers the Seriatim input adapter. |
|
||||
| `internal/modules/dnd/register` | Registers D&D modules, validators, default validator policy, and prompt/schema assets. |
|
||||
|
||||
- Source-format details stay in input modules and integration docs.
|
||||
- Extraction-domain details stay in extract modules and artifact docs.
|
||||
- Provider wire details stay in the LLM runtime and provider integration docs.
|
||||
- Durable output contracts belong in integration docs.
|
||||
- Operator procedures belong in `docs/operations.md`, not internal docs.
|
||||
The CLI allocates the framework registries and asset registry, then invokes
|
||||
these registrars in generic, Seriatim, and D&D order.
|
||||
|
||||
Implementation details for all production extensions are in
|
||||
[Module Internals](modules.md).
|
||||
|
||||
## Run-State Components
|
||||
|
||||
| Surface | Implemented owners | Internal purpose |
|
||||
| --- | --- | --- |
|
||||
| Durable output | Output module, pipeline runner, and CLI writer | Return logical consumer files and place them for a run. |
|
||||
| Cache checkpoints | `internal/framework/checkpoint` and `internal/cli` | Validate and serialize reusable extract, merge, and normalize outcomes, including ordered-step scope and generated-artifact dependency decisions. |
|
||||
| Chunk-plan cache | `internal/framework/chunkplan` and `internal/cli` | Persist and select source-addressed plans before framework materialization. |
|
||||
| Debug bundles | `internal/core/debugbundle`, `internal/framework/debug`, and pipeline instrumentation | Persist redacted summaries and application-owned traces. |
|
||||
|
||||
Physical layout, cleanup, recovery, and sensitive-data handling are defined
|
||||
in [Operations](../operations.md). Concrete modules receive recorder
|
||||
interfaces and request data, not physical state roots.
|
||||
|
||||
## Focused Documentation
|
||||
|
||||
- [Pipeline Internals](pipeline.md): resolution, execution, validation, retries,
|
||||
checkpoint/debug hooks, and result assembly.
|
||||
- [Module Internals](modules.md): production modules, validators, assets,
|
||||
registration, and the contributor recipe for adding an extension.
|
||||
- [LLM Runtime](llm.md): structured completion contracts, Scriptorium adapter,
|
||||
assets, scheduling, profile recording, and redaction.
|
||||
- [Run State Internals](state.md): output, cache, debug collaborator
|
||||
composition, and path safety.
|
||||
|
||||
@@ -1,127 +1,462 @@
|
||||
# Pipeline Internals
|
||||
|
||||
The implemented pipeline runner lives in `internal/framework/pipeline`. It
|
||||
executes the fixed workflow defined by the architecture policy:
|
||||
The implemented resolver and runner live in `internal/framework/pipeline`.
|
||||
Their fixed workflow and ownership boundaries are defined by
|
||||
[Architecture](../policy/architecture.md#system-shape). Configuration fields,
|
||||
defaults, and selectable keys are defined in
|
||||
[Configuration](../config.md#pipelines).
|
||||
|
||||
```text
|
||||
input -> chunk -> extract -> merge -> normalize -> output
|
||||
```
|
||||
Resolution fixes the ordered steps, selected lanes, and all stage bindings;
|
||||
preparation constructs every selected implementation before the runner begins
|
||||
source work. After serial input parsing and plan selection or generation, the
|
||||
runner materializes chunks and executes one step at a time. Within a step,
|
||||
extract work uses one bounded run-wide worker pool in chunk-first, lane-second
|
||||
order. Each lane's merge and normalize operations remain serial, and lanes in
|
||||
the same step may overlap once their extracts are terminal. A later step cannot
|
||||
start across its barrier until every earlier lane is terminal and each required
|
||||
generated artifact has been accepted and handed off.
|
||||
|
||||
Pipeline execution is serial. The runner executes the resolved lanes one after
|
||||
another in the fixed workflow order.
|
||||
## Resolution
|
||||
|
||||
## Profile Resolution
|
||||
`internal/core/config.Config.Resolve` validates the loaded configuration,
|
||||
selects the named profile, applies the runtime inputs supplied by the CLI, and
|
||||
calls `pipeline.ResolvePipeline`.
|
||||
|
||||
Config loading produces `pipeline.PipelineProfile` values. Resolution happens
|
||||
before execution:
|
||||
`ResolvePipeline`:
|
||||
|
||||
1. `internal/core/config.Config.Resolve` validates config and finds the named
|
||||
pipeline.
|
||||
2. The optional lane selection is passed to `pipeline.ResolvePipeline`.
|
||||
3. Module bindings are defaulted:
|
||||
- chunk: `generic`
|
||||
- merge: `appendorder`
|
||||
- normalize: `noop`
|
||||
- output: `json`
|
||||
- LLM profile: `default`
|
||||
4. The module catalog is checked for each bound module key.
|
||||
5. Module capabilities are checked in workflow order.
|
||||
6. A digest is calculated from the resolved pipeline without the digest field.
|
||||
1. selects the explicit ordered steps, or creates the implicit `default` step
|
||||
from the legacy top-level `artifacts` map;
|
||||
2. selects and sorts artifact lanes within each step while enforcing global lane
|
||||
identity;
|
||||
3. completes omitted bindings using the documented configuration defaults;
|
||||
4. looks up each module and validator spec without constructing it;
|
||||
5. for a typed extractor, derives its artifact kind, requires the codec, and
|
||||
selects exact-type merger, normalizer, and validator variants;
|
||||
6. checks required and provided capabilities in workflow order;
|
||||
7. resolves external and generated target-aware reference bindings and
|
||||
validates producer order, consumer slot declarations, and artifact-kind
|
||||
compatibility;
|
||||
8. validates each selected module and validator option set through its registry
|
||||
entry; and
|
||||
9. calculates a digest over the resolved structure, including step order, step
|
||||
IDs, lane membership, generated topology, producer and consumer identities,
|
||||
typed artifact kind and schema identity, and the effective validator policy
|
||||
in its resolved execution order.
|
||||
|
||||
The CLI writes the resolved pipeline and digest to diagnostics.
|
||||
Resolution returns a `ResolvedPipeline` containing ordered steps, lanes,
|
||||
concrete bindings, validator chains, reference targets, and the digest. It does
|
||||
not read external reference bytes or construct runtime modules. CLI lane and
|
||||
reference selector syntax is defined in the [CLI reference](../cli.md#run).
|
||||
|
||||
## Registries And Module Specs
|
||||
The digest includes each resolved step's ID and lane membership, generated
|
||||
producer/consumer topology, and each validator chain's stage, lane, owning
|
||||
module, ordered validator bindings, execution classes, targets, and artifact
|
||||
kinds. Changing step order, a dependency, a default chain, or an explicit
|
||||
override therefore changes pipeline identity whenever it changes effective
|
||||
execution policy.
|
||||
|
||||
`pipeline.Registries` holds concrete constructors for execution. A
|
||||
`pipeline.ModuleCatalog` exposes module specs for config validation and
|
||||
resolution.
|
||||
## Reference Materialization
|
||||
|
||||
Every production module registers a `ModuleSpec` with:
|
||||
The CLI calls `MaterializeReferences` after resolution and before constructing
|
||||
the LLM client or running the pipeline. For external bindings, the materializer
|
||||
checks each binding against its resolved target declaration, reads and validates
|
||||
the file, and builds both a `contracts.ReferenceSet` and provenance-only
|
||||
metadata on the corresponding `ResolvedReferenceTarget`. A structured
|
||||
generated binding is declaration-only at this point: its producer bytes do not
|
||||
exist until the producer lane reaches an accepted normalized result.
|
||||
|
||||
- `Key`: module key used in config;
|
||||
- `Stage`: module kind such as input, chunk, extract, merge, normalize,
|
||||
validate, or output;
|
||||
- `Provides`: capabilities added after that module runs;
|
||||
- `Requires`: capabilities that must already be available.
|
||||
Preparation delivers the materialized external set for each target through
|
||||
`pipeline.BuildRequest`: chunkers and chunk validators receive the chunk target;
|
||||
extractors and extract validators receive the lane extract target; mergers and
|
||||
merge validators receive the lane merge target; and normalizers and normalize
|
||||
validators receive the lane normalize target. Input and output builders receive
|
||||
an empty set because those stages cannot declare references. Every builder gets
|
||||
an isolated deep clone of its target set, so construction-time mutation cannot
|
||||
change another builder, the resolved pipeline, or later runtime requests.
|
||||
|
||||
Capability checks prevent incompatible pipeline composition before a run starts.
|
||||
Prepared consumers do not need to be reconstructed when generated content is
|
||||
available. At the step boundary, the runner encodes the accepted producer value
|
||||
through its registered canonical codec, validates the generated bytes against
|
||||
each target slot's kind, schema, media type, and size, and clones one immutable
|
||||
reference item into the operation request. The item includes canonical digest,
|
||||
size, and bounded producer provenance but no filesystem URI. A handoff failure
|
||||
is a framework dependency error and prevents every consumer in that step from
|
||||
starting.
|
||||
|
||||
## Runner Input And Output
|
||||
The runner continues to clone the resulting set into the chunk, extract, merge,
|
||||
or normalize request that owns the target. LLM-backed extensions may convert
|
||||
those items into named prompt inputs. Reference content remains separate from
|
||||
source evidence and source digests, whether the item came from a file or a
|
||||
generated handoff.
|
||||
|
||||
`pipeline.RunInput` carries:
|
||||
Binding precedence, path resolution, accepted content, and media-type behavior
|
||||
are configuration contracts; see [Configuration](../config.md#pipelines).
|
||||
Durable provenance is defined in the
|
||||
[JSON output contract](../integrations/json-output.md#manifestjson), while
|
||||
runtime sensitive-data handling belongs in [Operations](../operations.md).
|
||||
|
||||
- a `ResolvedPipeline`;
|
||||
- optional source ID, input path, and raw input bytes;
|
||||
- a structured LLM client;
|
||||
- run ID, start time, LLM profile manifest metadata, and CLI metadata.
|
||||
## Registries And Specs
|
||||
|
||||
`pipeline.RunOutput` carries:
|
||||
`pipeline.Registries` holds option validators and run-local builders used during
|
||||
resolution and preparation.
|
||||
`pipeline.ModuleCatalog` exposes their specs during configuration validation and
|
||||
resolution. Separate registries exist for every stage and for validators;
|
||||
`ValidatorChainRegistry` stores production default-chain mappings. Both
|
||||
containers also carry an `ArtifactCodecRegistry`. Generic registration records
|
||||
one codec per stable artifact kind, validates its schema metadata and JSON
|
||||
Schema, retains the exact schema digest and Go type, and safely encodes or
|
||||
decodes framework-erased values with typed errors on incompatibility.
|
||||
|
||||
- run manifest;
|
||||
- approved artifacts;
|
||||
- rejected artifacts;
|
||||
- warnings;
|
||||
- logical output files returned by the output encoder.
|
||||
Typed extractor entries are keyed by module key and declare one artifact kind.
|
||||
Merger, normalizer, and typed-validator variants are keyed by module or
|
||||
validator key plus artifact kind. Chunk and serialized validators occupy
|
||||
separate target namespaces; serialized registrations declare whether they
|
||||
support chunks, artifacts, or both. Duplicate variants and exact Go-type
|
||||
mismatches are rejected deterministically.
|
||||
|
||||
The CLI owns durable file writes and diagnostics writes after the runner returns.
|
||||
Lane-sensitive merger and normalizer spec discovery always supplies the
|
||||
extractor's artifact kind, so variants under one reusable key may declare
|
||||
different capabilities and reference slots. Kind-neutral registry inspection
|
||||
selects the first registered artifact kind in sorted order.
|
||||
|
||||
## Execution
|
||||
Production composition registers the D&D spell-list, NPC-list, combat-turn-list,
|
||||
NPC-interaction-list, and scene-description-list codecs and typed lane
|
||||
variants, plus serialized JSON validators. Every artifact lane resolves through
|
||||
the typed registries and a matching codec.
|
||||
|
||||
A `ModuleSpec` declares its stage plus required and provided capabilities.
|
||||
Chunk, extract, merge, and normalize specs may also declare reference slots.
|
||||
Registry implementations defensively copy spec metadata, reject duplicate keys,
|
||||
and verify that a constructed implementation reports the registered key.
|
||||
Builder registrations accept `ModuleDependencies` and cloned configuration
|
||||
options through one `BuildRequest`. Builders decode those options and retain
|
||||
typed values or injected dependencies in the constructed implementation.
|
||||
Extractors declare their artifact kind, and merger, normalizer, and validator
|
||||
resolution selects the matching typed variant.
|
||||
|
||||
A `ValidatorSpec` declares a validator key and execution class. Resolution uses
|
||||
the execution class to reject incompatible profile bindings before execution.
|
||||
The current production catalog and default chain are listed only in
|
||||
[Configuration](../config.md#implemented-production-validators).
|
||||
|
||||
## Preparation And Runner Boundary
|
||||
|
||||
`pipeline.Prepare` receives a resolved pipeline, the registries, and shared
|
||||
module dependencies. It constructs input; chunk and its validators; every
|
||||
step's lane extract, merge, and normalize modules and validator chains in
|
||||
resolved order; then output. It stops at the first error with pipeline, step,
|
||||
stage, lane, module, and validator context as applicable. It never invokes an
|
||||
operation method. Generated references are not available during preparation;
|
||||
the operation request is the handoff boundary.
|
||||
|
||||
`PreparedPipeline` keeps private constructed executors and exposes cloned
|
||||
resolved input, chunk, lane, and output identities. Prepared components may
|
||||
implement `pipeline.CheckpointFingerprintProvider` to contribute explicit
|
||||
semantic identities to checkpoint reuse. Preparation trims and validates each
|
||||
non-secret name and value, prefixes it with the component's stage, lane,
|
||||
module, and validator scope, rejects duplicates, and retains the resulting
|
||||
sorted collection behind a defensive-copy accessor. Fingerprints must be
|
||||
stable and must not contain source content, credentials, local paths,
|
||||
timestamps, or other invocation-specific values.
|
||||
|
||||
`pipeline.RunInput` carries that prepared pipeline, raw source input, run identity and timing, optional
|
||||
session and profile metadata, a chunk-plan store and mode, a checkpoint
|
||||
execution policy, and checkpoint/debug collaborators. The runner
|
||||
parses source bytes through the already constructed input adapter. Later stage
|
||||
requests receive the generic source model; extract requests receive
|
||||
chunk-scoped input material, while chunk, merge, and normalize requests retain
|
||||
access to the original source material. Input, chunk, and output operation
|
||||
requests do not carry raw module options. The chunk request also does not carry
|
||||
an LLM client; an LLM-backed chunker receives the shared client during
|
||||
preparation. Their operation requests retain run-specific source, reference,
|
||||
profile, session, metadata, and step-handoff context as applicable. A generated
|
||||
reference is cloned into each compatible consumer request and is never exposed
|
||||
as a path.
|
||||
|
||||
Prepared lanes retain exact-type-checked erased operation closures. The runner
|
||||
uses those closures to keep each value typed through extraction, validation,
|
||||
merge, and normalization.
|
||||
|
||||
Source validation requires every unit to carry a canonical self-reference to
|
||||
its containing document and its own unit ID. Explicit clone, checkpoint, and
|
||||
debug boundaries retain that reference, and the canonical source digest covers
|
||||
it deterministically. Chunks use the same source model and carry one canonical
|
||||
reference spanning the first selected unit through the last.
|
||||
|
||||
`pipeline.RunOutput` carries the run manifest, accepted normalized serialized
|
||||
artifacts with lane and normalizer provenance,
|
||||
rejected results, warnings, checkpoint events, and logical files returned by the
|
||||
output encoder. The CLI owns debug-summary and durable filesystem writes after
|
||||
the runner returns.
|
||||
|
||||
## Execution Flow
|
||||
|
||||
The pipeline-wide coordinator owns the ordered step loop, generated-reference
|
||||
sets at each barrier, and deterministic merging of step outcomes. For one step,
|
||||
one run-local lane engine owns worker lifecycle, cancellation, dispatch,
|
||||
continuation queues, and result collection. It initializes checkpoint state in
|
||||
lane order, dispatches bounded extract work, advances terminal lanes through
|
||||
serial merge and normalize work, selects failures by stable pipeline scope, and
|
||||
merges lane-local outcomes back in resolved order. Completion timing never
|
||||
becomes public ordering.
|
||||
|
||||
The runner:
|
||||
|
||||
1. validates run input and registries;
|
||||
2. builds the input adapter and parses the raw input into a source document;
|
||||
3. validates the source document;
|
||||
4. builds the chunker and produces source chunks;
|
||||
5. runs each selected artifact lane in sorted resolved order;
|
||||
6. builds the output encoder and validates logical output file names.
|
||||
1. validates its prepared input;
|
||||
2. parses the raw input with the prepared adapter and validates the generic
|
||||
source document;
|
||||
3. selects a stored plan or executes the configured chunker's `Plan` operation;
|
||||
4. canonicalizes and materializes the plan, then validates the resulting
|
||||
chunks;
|
||||
5. builds the framework-owned accepted chunk map from the accepted source,
|
||||
logical plan, and exact materialized chunks, then supplies it to the output
|
||||
request independently of output-module options;
|
||||
6. executes each resolved step in configuration order. For one step, it
|
||||
dispatches extract jobs in source-chunk then resolved-lane order, starts a
|
||||
bounded lane continuation when all extracts for that lane are terminal, and
|
||||
waits for every lane to become terminal;
|
||||
7. encodes and validates each accepted normalized producer artifact, then
|
||||
builds the immutable generated reference sets for the next step;
|
||||
8. invokes the prepared output encoder only after every step succeeds and
|
||||
validates its logical file results;
|
||||
9. returns the assembled manifest, outcomes, warnings, and files.
|
||||
|
||||
Within an artifact lane, the runner:
|
||||
Within each artifact lane, it reuses the prepared extractor, merger, normalizer,
|
||||
and validators while performing these transitions:
|
||||
|
||||
1. builds the extractor, merger, and normalizer;
|
||||
2. records module manifest metadata when modules provide it;
|
||||
3. extracts candidates from each chunk;
|
||||
4. normalizes candidate envelope fields such as index, extractor key, artifact
|
||||
type, and schema version;
|
||||
5. merges candidates;
|
||||
6. normalizes merged candidates;
|
||||
7. validates candidate envelope consistency;
|
||||
8. runs validators;
|
||||
9. converts approved candidates to artifacts.
|
||||
1. extract once per accepted chunk and add runner-owned lane, source, and chunk
|
||||
provenance;
|
||||
2. validate each extract result and omit rejected results from merge input;
|
||||
3. skip the rest of the lane when no extract result is accepted;
|
||||
4. merge accepted extract results in their existing order;
|
||||
5. validate the merge result and skip normalization on rejection;
|
||||
6. normalize the accepted merge result;
|
||||
7. validate and append the accepted normalized result.
|
||||
|
||||
## Validators
|
||||
At a step barrier, a lane with no accepted normalized output is still a regular
|
||||
rejection unless a later generated binding names that lane as a required
|
||||
producer. In that case the runner raises a deterministic dependency error and
|
||||
does not start the consumer step. One accepted typed artifact may fan out to
|
||||
multiple compatible target slots. Consumers in the same step may run
|
||||
concurrently after the handoff; no work crosses the barrier early.
|
||||
|
||||
If a lane declares validators in config, the runner builds those validators from
|
||||
the validator registry. Otherwise it uses validators returned by the extractor.
|
||||
Module-provided warnings and payload warnings are promoted only from attempts
|
||||
whose results are accepted and used.
|
||||
|
||||
Each validator must return exactly one decision for each eligible candidate. The
|
||||
runner enforces decision cardinality with `internal/framework/validate`.
|
||||
Rejected candidates are removed before the next validator runs. Approved
|
||||
candidates continue through the chain.
|
||||
## Chunk Plans And Reuse
|
||||
|
||||
The production CLI currently registers no standalone validator modules. The
|
||||
current D&D spell extractor supplies deterministic shape and source-reference
|
||||
validators.
|
||||
`Chunker.Plan` returns a `source.ChunkPlan`: the canonical source digest,
|
||||
ordered unit-ID ranges, and optional plan or range annotations. The framework
|
||||
owns plan canonicalization and materialization. It creates the generic chunks
|
||||
and therefore owns their IDs, indexes, source references, JSON content, units,
|
||||
media type, and generic metadata. Plan and range annotations are independently
|
||||
owned raw JSON and become `Chunk.PlanAnnotations` and `Chunk.Annotations`.
|
||||
|
||||
## Warnings And Failures
|
||||
In `auto`, the runner looks up the source digest before invoking the chunker. A
|
||||
valid hit is materialized and sent through the current run's configured chunk
|
||||
validators; it does not invoke the chunk module, consume its retry budget, or
|
||||
make a chunk-stage LLM call. A missing, invalid, or unmaterializable record
|
||||
generates a candidate. `refresh` generates without lookup; `bypass` generates
|
||||
without cache access. Generated plans are published only after the full chunk
|
||||
validator chain approves them. A validator rejection is a regular rejected
|
||||
pipeline outcome and never replaces a cached plan.
|
||||
|
||||
Warnings from chunking, extraction, merging, normalization, validation, and
|
||||
output encoding are accumulated in `RunOutput.Warnings`.
|
||||
The store is source-addressed, not pipeline-addressed. Changes to pipeline
|
||||
configuration, requested chunker, options, references, lanes, validators, or
|
||||
LLM profile do not prevent a source-digest hit. The manifest records both the
|
||||
currently requested chunker and the effective plan producer. Cache state and
|
||||
paths are configured and operated outside the runner; see
|
||||
[Configuration](../config.md#state-surfaces) and [Operations](../operations.md).
|
||||
|
||||
Errors wrap the operation and module key or lane context. If execution fails
|
||||
after a manifest exists, the returned manifest is marked `failed` and receives a
|
||||
completion timestamp.
|
||||
For an accepted plan, the runner also constructs the strict framework-owned
|
||||
[Accepted Chunk Map](../integrations/chunk-map.md) before lane execution. It
|
||||
uses the current resolved chunker as `requested_chunker` and the stored or
|
||||
generated record as `producer`, preserving that distinction on reuse. Chunk
|
||||
rejection supplies no map; later lane rejection does not discard it. Output
|
||||
encoders receive a defensively owned serialized value and may explicitly
|
||||
ignore it.
|
||||
|
||||
On successful execution, the manifest validation status is:
|
||||
The extract job channel has the same capacity as the effective extract worker
|
||||
count, so dispatch applies backpressure. A fixed continuation executor prevents
|
||||
ready or checkpoint-reused lanes from creating one goroutine each. Workers and
|
||||
continuations publish lane-local results; the coordinator is the only writer of
|
||||
aggregate output and merges those results in resolved lane and source-chunk
|
||||
order.
|
||||
|
||||
- `approved` when no candidates were rejected;
|
||||
- `rejected` when at least one candidate was rejected.
|
||||
## Plan Canonicalization And Chunk Materialization
|
||||
|
||||
## Manifest Population
|
||||
Plan canonicalization requires canonical JSON annotations, a matching source
|
||||
digest, at least one range, existing ordered boundaries, and increasing range
|
||||
starts. Ranges may overlap or leave gaps; a chunker may impose stricter policy.
|
||||
Materialization deterministically reconstructs each range from the current
|
||||
source document, deep-clones JSON-shaped source-unit metadata, and copies
|
||||
annotations without interpreting their namespaces. Materialized chunks and
|
||||
separate materializations do not share mutable unit metadata; unsupported or
|
||||
cyclic metadata fails materialization with context.
|
||||
|
||||
The manifest records run ID, pipeline ID, pipeline digest, module keys, artifact
|
||||
lanes, LLM profile metadata, source digest, validation status, and timing.
|
||||
Before lane execution, generic chunk validation checks the materialized chunks'
|
||||
identities, order, source references, content, media type, units, and metadata.
|
||||
No chunk checkpoint participates in plan selection: plan storage is the only
|
||||
chunk-reuse mechanism. Extract, merge, and normalize checkpoints continue to
|
||||
use materialized chunk digests as their dependencies.
|
||||
|
||||
Modules can add non-secret manifest metadata by implementing
|
||||
`contracts.ManifestMetadataProvider`. The D&D spell extractor uses this for
|
||||
prompt and response-schema provenance.
|
||||
## Validation And Retries
|
||||
|
||||
Chunk, extract, merge, and normalize results pass through the resolved validator
|
||||
chain for their stage and module. Chunk validators receive canonical chunks;
|
||||
typed validators receive the domain value; and serialized validators receive
|
||||
canonical chunk JSON or artifact codec bytes. Validators execute in resolved
|
||||
order and stop at the first error or rejection. An empty chain approves the
|
||||
result.
|
||||
|
||||
Production D&D artifact chains keep generic JSON syntax validation first, then
|
||||
run every rejecting domain validator before generic JSON Schema validation. The
|
||||
domain validator therefore owns expected semantic diagnostics; the generic
|
||||
schema validator remains the final rejecting representation backstop, before
|
||||
warning-only relatedness validation. Explicitly configured validator chains
|
||||
retain their configured order.
|
||||
|
||||
`runWithRetry` applies the effective retry policy around module execution and
|
||||
its complete validation chain. A module or validator error becomes a framework
|
||||
error when attempts are exhausted. A rejection becomes a recorded
|
||||
`RejectedOutput` when attempts are exhausted. Cancellation stops retry
|
||||
processing immediately.
|
||||
|
||||
Rejected output is a non-fatal pipeline outcome and does not advance. Warnings
|
||||
from discarded attempts are not promoted. Configuration owns retry counts and
|
||||
validator overrides; see [Module Bindings](../config.md#module-bindings).
|
||||
|
||||
## Checkpoint And Debug Hooks
|
||||
|
||||
The runner depends on recorder and loader interfaces, using no-op
|
||||
implementations when collaborators are absent. Each checkpointed workflow
|
||||
boundary records a running, succeeded, or failed transition. Reuse decisions
|
||||
are consulted in workflow order and accepted payloads are cloned before
|
||||
entering the normal handoff path. Typed extract, merge, and normalize
|
||||
checkpoints store codec bytes with artifact kind, schema ID, name, version and
|
||||
exact digest, and media type. Reuse compares that identity with the prepared
|
||||
codec and decodes through the codec; missing identity, mismatches, corrupt
|
||||
bytes, and decode failures become explicit reuse misses and execute the lane
|
||||
normally. Dependency fingerprints and debug content digests use the same stable
|
||||
codec bytes that cross those boundaries.
|
||||
|
||||
That progressive extract, merge, and normalize reuse is the ordinary resume
|
||||
path. A lane marked as a required predecessor for selective recomputation takes
|
||||
a separate accepted-output path before extract scheduling. The loader reads the
|
||||
existing successful normalize manifest and payload by step, lane, and
|
||||
normalizer, without consulting extract or merge dependencies. It requires the
|
||||
current non-empty checkpoint identity to match, so the invocation identity
|
||||
still binds the input, resolved topology and configuration, references, runtime
|
||||
overrides, profiles, and component fingerprints.
|
||||
|
||||
The runner decodes and canonically re-encodes each reusable artifact once with
|
||||
the prepared codec, requiring exact kind, schema identity and digest, media
|
||||
type, canonical bytes, content digest, and producer provenance. A valid accepted
|
||||
producer becomes a runner-owned cloned normalized output, restores only
|
||||
normalize-checkpoint warnings, and records one `accepted_artifact_reused`
|
||||
normalize decision. It does not invoke or record extract, merge, normalize, or
|
||||
their validators. Invalid or unavailable accepted state records its decision
|
||||
and fails the producer step; the dependent step never starts and the producer
|
||||
is not implicitly rerun. If a later required lane fails during initialization,
|
||||
already hydrated terminal lanes remain in the failed output in resolved order.
|
||||
|
||||
Generated references add downstream dependencies containing the producer's
|
||||
artifact kind, complete schema identity, media type, canonical content digest,
|
||||
and size. Compatible accepted producer outputs may therefore feed a later step
|
||||
without re-executing the producer. Forced lanes bypass accepted-output
|
||||
hydration and execute normally. A missing, rejected, corrupt, incompatible, or
|
||||
changed producer blocks its dependent while leaving independent work eligible
|
||||
for reuse. The runner records bounded decision
|
||||
categories: `reused`, `executed`, `forced_recompute`, and
|
||||
`dependency_invalidated`. Operator meanings for the stable reason codes belong
|
||||
to [Operations](../operations.md#resume-and-selective-recompute).
|
||||
|
||||
The CLI includes prepared-component fingerprints in the run-wide checkpoint
|
||||
identity alongside resolved configuration, raw input, reference provenance,
|
||||
runtime overrides, and LLM-profile fingerprints. Module metadata is not used
|
||||
implicitly for cache identity: components opt in only with stable semantic
|
||||
values that can change accepted output. Adding or changing a component
|
||||
fingerprint intentionally produces a cold cache miss. Existing checkpoint
|
||||
schemas and paths remain unchanged.
|
||||
|
||||
The CLI's `--recompute-step` policy forces the selected step and all transitive
|
||||
dependents, but requires accepted normalized artifacts for every unselected
|
||||
producer on which that closure depends. It changes execution policy only; it
|
||||
does not alter persistent checkpoint identity.
|
||||
|
||||
Debug instrumentation wraps run, stage, attempt, validator, and structured LLM
|
||||
boundaries. Every executed chunk, extract, merge, and normalize attempt writes
|
||||
one terminal envelope for acceptance, validator rejection, module or validator
|
||||
error, or applicable candidate or final serialization error. The envelope
|
||||
contains its attempt-local warnings, any available candidate and rejection,
|
||||
and terminal error text; failures before a candidate exists omit that payload.
|
||||
Only LLM calls made by the module operation belong to the module attempt.
|
||||
Validator calls retain independent scopes under `validate/` and are not
|
||||
duplicated into the module envelope. A failed terminal-envelope write is a
|
||||
non-retryable framework error and is joined with any primary attempt error.
|
||||
Debug data is never used as a checkpoint source. Typed artifact debug envelopes
|
||||
are domain-neutral, redact sensitive metadata and bytes through the common
|
||||
debug policy, and record codec identity plus schema and content digests.
|
||||
|
||||
Merge and normalize attempts serialize their in-memory candidate with the
|
||||
codec's required candidate encoder before typed validation. Serialized
|
||||
validators and attempt debug use that candidate representation, which carries
|
||||
the codec media type and schema identity but is never checkpointed or passed
|
||||
downstream. Only a validator-approved value is encoded through the strict final
|
||||
codec and made eligible for a checkpoint or stage output.
|
||||
|
||||
Checkpoint identity, physical layout, reuse behavior, and debug artifact
|
||||
handling are operator contracts in [Operations](../operations.md). Serialization
|
||||
and recorder implementation are inventoried in
|
||||
[Internal Overview](overview.md#run-state-components).
|
||||
|
||||
## Results And Failures
|
||||
|
||||
The runner owns manifest assembly and handoff summaries but not the durable JSON
|
||||
schema. It records resolved module and lane provenance, validator chains,
|
||||
source/reference identities, selected LLM profiles, normalized and rejected
|
||||
summaries, status, and timing. Serialized artifact content remains outside the manifest.
|
||||
Module metadata providers may add non-secret singleton or lane-scoped metadata.
|
||||
|
||||
Execution errors include stage, module, lane, or validator context. Once a
|
||||
manifest exists, a failing run returns it with failed status and completion
|
||||
time. Successful status reflects whether any result was rejected. The
|
||||
durable manifest and logical file schemas are defined in the
|
||||
[JSON output contract](../integrations/json-output.md).
|
||||
|
||||
On a framework failure, the runner cancels its derived context, stops submitting
|
||||
new extract work, drains started tasks, and skips the output encoder. Parent
|
||||
cancellation takes precedence. Otherwise context-cancellation fallout is
|
||||
discarded when a substantive error exists, and the primary error is selected by
|
||||
stage, resolved lane, and source chunk rather than completion time.
|
||||
|
||||
## Tests To Inspect
|
||||
|
||||
- `internal/core/config/effective_config_test.go`: config-to-resolution boundary.
|
||||
- `internal/framework/pipeline/profile_test.go`: selection, defaults,
|
||||
capabilities, validator chains, and digest behavior.
|
||||
- `internal/framework/pipeline/artifact_codec_registry_test.go`: typed codec
|
||||
metadata, registration, erasure safety, strict decoding, and cloning.
|
||||
- `internal/framework/pipeline/typed_resolution_test.go`: heterogeneous typed
|
||||
lane resolution and preparation, target-specific validators,
|
||||
incompatibilities, ordering, and schema-sensitive pipeline identity.
|
||||
- `internal/framework/pipeline/runner_concurrency_test.go`: bounded dispatch and
|
||||
continuations, reverse completion, stable errors, rejection, cancellation,
|
||||
retries, and independent provider-call limits.
|
||||
- `internal/framework/pipeline/preparation_test.go`: option validation,
|
||||
construction order, dependency failures, and the before-source-work boundary.
|
||||
- `internal/framework/pipeline/references_test.go`: target resolution and
|
||||
materialization.
|
||||
- `internal/cli/run_contract_test.go`: production run transitions, retries,
|
||||
rejections, warnings, CLI recomputation controls, debug hooks, and manifests.
|
||||
- `internal/cli/recompute_execution_contract_test.go`: filesystem-backed
|
||||
selective recomputation and accepted-producer recovery.
|
||||
- `internal/cli/production_contract_test.go`: production composition and
|
||||
configuration-resolution smoke coverage.
|
||||
- `internal/cli/example_contract_test.go`: maintained example resolution and
|
||||
execution ownership.
|
||||
- `internal/modules/integration/*_test.go` and
|
||||
`internal/modules/seriatim/input/transcript/runner_test.go`: typed runner
|
||||
composition across concrete module families.
|
||||
- `internal/framework/checkpoint/*_test.go`: checkpoint serialization and reuse
|
||||
collaborators.
|
||||
|
||||
116
docs/internal/state.md
Normal file
116
docs/internal/state.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Run State Internals
|
||||
|
||||
This document describes the implementation collaborators behind output, cache,
|
||||
and debug state. User-visible fields belong in [Configuration](../config.md),
|
||||
and layouts and lifecycle belong in [Operations](../operations.md).
|
||||
|
||||
## Composition
|
||||
|
||||
`internal/cli` is the only physical-path composition root. It resolves the
|
||||
effective configuration, selects exact roots, allocates requested debug bundles,
|
||||
constructs cache collaborators, writes logical output files, and reports paths.
|
||||
Pipeline modules receive interfaces and request data, never output, cache, or
|
||||
debug roots.
|
||||
|
||||
## Output And Cache
|
||||
|
||||
The pipeline runner returns logical output files. After validating every
|
||||
logical name, the CLI exclusively creates the run directory beneath the
|
||||
selected output root and performs confined, atomic file writes within it.
|
||||
The runner supplies an accepted chunk map as an optional, defensively owned
|
||||
output-request artifact. The JSON encoder alone decides whether its explicit
|
||||
option writes the map and optional index descriptor; neither the map payload
|
||||
nor its annotations are copied into the run manifest. The durable fields are
|
||||
owned by the [Accepted Chunk Map contract](../integrations/chunk-map.md).
|
||||
|
||||
`internal/framework/chunkplan` owns source-addressed plan storage, validation,
|
||||
and atomic publication. Its store is constructed only when the selected mode is
|
||||
not `bypass`.
|
||||
|
||||
`internal/framework/checkpoint` owns checkpoint identity, manifests, payload
|
||||
codecs, loader, and recorder. The CLI constructs a recorder whenever checkpoint
|
||||
recording is enabled and constructs a loader only for a `--resume` invocation.
|
||||
Identity incorporates explicit stable semantic fingerprints collected from
|
||||
prepared modules and validators in addition to configuration, input,
|
||||
references, runtime overrides, and LLM profiles.
|
||||
The serialized
|
||||
`workspace_schema_version` identifiers are frozen wire-compatibility fields;
|
||||
they do not describe a current public state surface.
|
||||
|
||||
Ordered-step lane checkpoints include the step identity in their storage scope.
|
||||
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
|
||||
when progressively loading consumer stage checkpoints, so changed producer
|
||||
content produces `dependency_invalidated` rather than stale downstream reuse.
|
||||
Selective recomputation instead requires each unselected producer's accepted
|
||||
normalized artifact; invalid accepted state records its specific bounded reason
|
||||
and stops before the dependent. The selected step and its transitive dependents
|
||||
record `forced_recompute`.
|
||||
|
||||
Ordinary resume loads extract, merge, and normalize checkpoints progressively
|
||||
and may execute later lane stages after an earlier cache miss. Selective
|
||||
recomputation instead asks the loader for the required producer's accepted
|
||||
normalize artifact. That lookup reuses the existing normalize files, requires
|
||||
workspace schema v3 plus an exact non-empty invocation identity, and deliberately
|
||||
does not require extract or merge checkpoint files or dependency fingerprints.
|
||||
The runner performs canonical codec and producer-provenance validation before
|
||||
cloning the artifact into normal step output. Success restores only stored
|
||||
normalize warnings and emits one normalize decision; failure retains the files,
|
||||
records the decision, and stops without executing the producer or consumer.
|
||||
|
||||
The loader assigns a typed category and reason code at each validation site;
|
||||
diagnostic prose is not classified after the fact. The runner then applies
|
||||
forced-execution policy, validates reusable artifact bytes through the prepared
|
||||
codec once, returns the canonical hydrated value to the stage, and records the
|
||||
final decision before enforcing a required-predecessor failure. That failure
|
||||
names only the step, lane, and stable reason code. Decision detail is selected
|
||||
from code-owned descriptions by reason code and then UTF-8 normalized and
|
||||
bounded; callers cannot supply arbitrary diagnostic prose. Typed categories and
|
||||
codes remain intact through pipeline events and become strings only in manifest
|
||||
and debug-summary JSON. [Operations](../operations.md#resume-and-selective-recompute)
|
||||
is the canonical operator-facing reason-code reference.
|
||||
|
||||
`internal/core/fileio` provides confined atomic file writes used by state
|
||||
collaborators. The chunk-plan store retains its stronger entry validation.
|
||||
|
||||
## Debug Bundles
|
||||
|
||||
`internal/core/debugbundle` allocates an explicitly requested per-run bundle
|
||||
with `summary/` and `trace/` roots. `SummaryWriter` persists redacted command,
|
||||
resolution, run, warning, and failure artifacts. `internal/framework/debug`
|
||||
implements the pipeline-facing trace recorder under the trace root.
|
||||
|
||||
The CLI allocates a bundle before pipeline resolution and treats requested
|
||||
summary or trace persistence failures as command failures. The pipeline's debug
|
||||
boundaries redact sensitive metadata and credential-shaped bytes while allowing
|
||||
application-owned trace material. Debug data is never a checkpoint source or
|
||||
cache input.
|
||||
|
||||
Generated reference bytes exist only in cloned operation requests and are not
|
||||
written as paths into checkpoints, manifests, or debug summaries. Those state
|
||||
surfaces retain canonical identities and bounded producer provenance so that a
|
||||
resume decision can be explained without copying generated campaign content.
|
||||
|
||||
After allocation, one CLI-owned state value accumulates the known report paths,
|
||||
pipeline outcome counts, and validation status. A single guarded terminalization
|
||||
operation writes the success report, or makes one attempt each to write the
|
||||
failure report and error log. Terminal persistence failures are reported
|
||||
separately and never replace the command's primary error.
|
||||
|
||||
## Tests To Inspect
|
||||
|
||||
- `internal/cli/run_contract_test.go`: command-owned state allocation,
|
||||
terminalization, and output/report boundaries.
|
||||
- `internal/cli/state_hardening_test.go`: independent roots, reuse, failures,
|
||||
permissions, cleanup, and redaction.
|
||||
- `internal/cli/recompute_execution_contract_test.go`: selective recomputation,
|
||||
filesystem recovery, deterministic decisions, and failed predecessor state.
|
||||
- `internal/cli/production_contract_test.go`: production composition and
|
||||
configuration validation at the CLI boundary.
|
||||
- `internal/cli/example_contract_test.go`: maintained example ownership.
|
||||
- `internal/core/debugbundle/*_test.go`: bundle allocation and summary writes.
|
||||
- `internal/framework/checkpoint/*_test.go`: checkpoint serialization and
|
||||
reuse.
|
||||
- `internal/framework/chunkplan/store_test.go`: plan envelope, confinement,
|
||||
publication, and permissions.
|
||||
@@ -1,132 +1,295 @@
|
||||
# Operations
|
||||
|
||||
This is the canonical reference for operating implemented Notarius runs.
|
||||
This is the canonical guide to operating Notarius filesystem state. Command
|
||||
syntax is in the [CLI reference](cli.md); field definitions and precedence are
|
||||
in [Configuration](config.md).
|
||||
|
||||
## Normal Run
|
||||
## State Model
|
||||
|
||||
A run reads one source file, resolves one configured pipeline, calls the
|
||||
configured OpenAI-compatible LLM profile, writes durable JSON output, and writes
|
||||
diagnostics for inspection.
|
||||
Notarius uses three independent filesystem surfaces:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config examples/dnd-spells.config.yml \
|
||||
--input examples/seriatim-minimal-transcript.json \
|
||||
--output-dir ./notarius-output \
|
||||
--diagnostics-dir /tmp/notarius
|
||||
```
|
||||
- output is durable user data;
|
||||
- cache is reconstructible chunk-plan and checkpoint state; and
|
||||
- debug is explicitly requested inspection data.
|
||||
|
||||
The command prints a success line with the pipeline ID, approved and rejected
|
||||
artifact counts, and the output path.
|
||||
Choose separate roots and access controls for each surface. A normal run writes
|
||||
durable output, may use the chunk-plan cache, and records checkpoints when
|
||||
`cache.checkpoints.enabled` is true. It does not create debug state unless its
|
||||
invocation includes `--debug`.
|
||||
|
||||
## Output Directory
|
||||
## Output
|
||||
|
||||
Durable output is written to:
|
||||
Durable logical files are written under:
|
||||
|
||||
```text
|
||||
<output-root>/<run-id>/
|
||||
```
|
||||
|
||||
The default output root is `./notarius-output`. Use `--output-dir` to choose a
|
||||
different root.
|
||||
The CLI generates one run ID in the form
|
||||
`run-<started-at-unix-nanoseconds>-<32-lowercase-hex-characters>` and uses it
|
||||
for output, manifests, and any requested debug bundle. It validates every
|
||||
logical output name before exclusively creating the run directory. If that
|
||||
directory already exists, the invocation fails without changing it.
|
||||
|
||||
The `json` output module writes these files:
|
||||
Each output file is written atomically. A later file-write failure leaves the
|
||||
newly allocated partial run directory in place for inspection; Notarius never
|
||||
automatically removes output. The
|
||||
[JSON output contract](integrations/json-output.md) owns the logical file
|
||||
names, schemas, and media types inside a run directory.
|
||||
|
||||
- `index.json`: file index with paths to the manifest, artifact files,
|
||||
rejected artifacts, and warnings.
|
||||
- `manifest.json`: run manifest with resolved pipeline provenance, module keys,
|
||||
validation status, and timing.
|
||||
- `artifacts/<artifact-type>.json`: approved artifacts grouped by artifact
|
||||
type. For the current D&D spell extractor, this includes
|
||||
`artifacts/dnd.spell_cast.json` when spell-cast artifacts are approved.
|
||||
- `rejected.json`: rejected candidates and validator decisions.
|
||||
- `warnings.json`: warnings reported by pipeline modules or the output encoder.
|
||||
An enabled JSON `include_chunk_map` option adds an accepted chunk map to durable
|
||||
output. Its annotations may contain source- or model-derived data, so retain
|
||||
and protect it like lane output. The map is opt-in and does not alter existing
|
||||
bundles; its payload exclusions are defined in the
|
||||
[Accepted Chunk Map contract](integrations/chunk-map.md).
|
||||
|
||||
Output writes are atomic per file. Logical output file names must be clean,
|
||||
relative, slash-separated paths and must not contain `..`.
|
||||
Remove an output run directory only after its consumer data is no longer
|
||||
needed. This is data deletion, not cache cleanup.
|
||||
|
||||
## Diagnostics Directory
|
||||
## Ordered D&D Workflow
|
||||
|
||||
Diagnostics are written under:
|
||||
The maintained [complete D&D configuration](../examples/dnd-complete.config.yml)
|
||||
contains one pipeline with two ordered steps. The first step extracts and
|
||||
normalizes NPCs and scene descriptions. Only after those lanes reach accepted
|
||||
terminal results does the second step begin; its generated NPC reference is
|
||||
supplied in memory to spell, combat-turn, and NPC-interaction extraction and
|
||||
the applicable normalizers.
|
||||
|
||||
```text
|
||||
<diagnostics-work-dir>/<run-id>/
|
||||
```sh
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config examples/dnd-complete.config.yml \
|
||||
--input examples/seriatim-minimal-transcript.json \
|
||||
--output-dir ./npc-grounded-output
|
||||
```
|
||||
|
||||
The default diagnostics work directory is `/tmp/notarius`. It can be set with
|
||||
`diagnostics.work_dir`, `NOTARIUS_WORK_DIR`, or `--diagnostics-dir`.
|
||||
The NPC artifact grounds canonical names through a names-only prompt projection, not spell or combat
|
||||
evidence. Current-transcript source ranges remain the only event evidence. The
|
||||
manifest records generated-reference identity and bounded producer provenance;
|
||||
it does not record generated payload content, and no generated content is
|
||||
exposed through a filesystem path. The same producer artifact may fan out to
|
||||
compatible consumers, while a missing or rejected producer prevents the later
|
||||
step from starting.
|
||||
|
||||
Implemented diagnostics artifacts:
|
||||
Standalone module configurations continue to support external NPC files when a
|
||||
workflow intentionally crosses a process or session boundary. Those files are
|
||||
validated against the consumer slot and must be protected as sensitive
|
||||
campaign data. They are not part of the maintained ordered handoff workflow.
|
||||
|
||||
- `invocation.json`: command metadata such as operation, config path, input
|
||||
path, selected lanes, run ID, and pipeline digest when available.
|
||||
- `effective-config.json`: resolved config with API keys redacted.
|
||||
- `resolved-pipeline.json`: resolved module bindings and pipeline digest.
|
||||
- `run-manifest.json`: the same run manifest written to durable output when it
|
||||
is available.
|
||||
- `warnings.json`: warning list.
|
||||
- `run-report.json`: counts, status, output path, diagnostics path, and run ID.
|
||||
- `error.log`: failure message, written after diagnostics directory creation
|
||||
when a run fails.
|
||||
## Chunk-Plan Cache
|
||||
|
||||
`source-document.json` is supported by the diagnostics writer but is not written
|
||||
by the current CLI run workflow.
|
||||
Chunk plans are stored at:
|
||||
|
||||
## Retention
|
||||
```text
|
||||
<chunk-plan-root>/<source-sha256-hex>/plan.json
|
||||
```
|
||||
|
||||
Diagnostics retention is configured with `diagnostics.retention`,
|
||||
`NOTARIUS_DIAGNOSTICS_RETENTION`, or the default `auto`.
|
||||
`auto` reuses a complete valid plan or regenerates missing or invalid state.
|
||||
`refresh` regenerates and atomically replaces a plan after chunk validation.
|
||||
`bypass` performs no plan-cache I/O and does not resolve or create the root.
|
||||
Plan selection is source-addressed and independent of checkpoint and debug
|
||||
roots.
|
||||
|
||||
- `auto`: keep failed runs and successful runs with warnings; remove successful
|
||||
warning-free runs.
|
||||
- `always`: keep every diagnostics run directory.
|
||||
- `never`: remove successful run directories; failed runs are still retained.
|
||||
When its directory is empty in configuration, the root is
|
||||
`<os.UserCacheDir>/notarius/chunk-plans`. A configured directory is the exact
|
||||
root; no suffix is appended. Directories and files created by the store use
|
||||
`0700` and `0600` permissions on supported Unix systems. The configured root
|
||||
is a trust boundary: do not share it among mutually untrusted users.
|
||||
|
||||
Unknown retention values are rejected during config validation.
|
||||
Remove an exact digest directory or the configured root only when accepting the
|
||||
cost of recomputing plans and any chunk-stage work. Cache publication is atomic;
|
||||
there is no history, locking, garbage collection, or rollback facility.
|
||||
|
||||
## Failures
|
||||
For a Linux service account, provision a dedicated restrictive root such as:
|
||||
|
||||
Failures before diagnostics directory creation, such as a missing config file or
|
||||
an unusable diagnostics work directory, are printed to stderr and may not have a
|
||||
diagnostics run directory.
|
||||
```yaml
|
||||
cache:
|
||||
chunk_plans:
|
||||
directory: /var/cache/notarius/chunk-plans
|
||||
```
|
||||
|
||||
Failures after diagnostics directory creation are printed to stderr and written
|
||||
to `error.log`. Depending on where the failure occurred, diagnostics may also
|
||||
include invocation metadata, redacted effective config, resolved pipeline data,
|
||||
the run manifest, warnings, and a run report.
|
||||
## Checkpoint Cache
|
||||
|
||||
If durable output writing fails after the pipeline completes, diagnostics are
|
||||
retained for inspection and may include `run-manifest.json`, `warnings.json`,
|
||||
`run-report.json`, and `error.log`.
|
||||
Checkpoint recording is controlled by `cache.checkpoints.enabled`, which
|
||||
defaults to `false`. When enabled, every run records running, succeeded, and
|
||||
failed transitions and reusable validator-approved results. Successful,
|
||||
rejected, and failed runs may therefore all leave checkpoint state. The
|
||||
`--resume` flag additionally loads compatible completed work before executing
|
||||
missing or incompatible stages. Without `--resume`, a recording-enabled run
|
||||
never loads checkpoints. Using `--resume` while recording is disabled is an
|
||||
error.
|
||||
|
||||
## Warnings
|
||||
Checkpoints use the selected root and the existing identity hierarchy:
|
||||
|
||||
A successful run with warnings exits with code `0`, prints a warning count to
|
||||
stderr, and writes warnings to durable output and diagnostics when retained.
|
||||
```text
|
||||
<checkpoint-root>/<pipeline-id>/<input-key>-<source-or-input-digest>/<pipeline-digest>/<identity-digest>/...
|
||||
```
|
||||
|
||||
The run manifest `validation_status` indicates whether final artifacts were
|
||||
approved or rejected after validation.
|
||||
The final identity digest includes stable semantic fingerprints explicitly
|
||||
contributed by prepared modules and validators. Adding or changing one of
|
||||
these fingerprints intentionally causes a cold cache miss; old checkpoint
|
||||
directories are left in place and are never migrated or deleted automatically.
|
||||
|
||||
An empty configured directory selects
|
||||
`<os.UserCacheDir>/notarius/checkpoints`. The root is exact when configured.
|
||||
Created directories and files use `0700` and `0600` permissions on supported
|
||||
Unix systems.
|
||||
|
||||
Checkpoint payloads can contain source text, intermediate artifacts, metadata,
|
||||
warnings, and content digests. Treat them as sensitive derived application
|
||||
data. Compatible files from a former checkpoint root remain reusable when
|
||||
`cache.checkpoints.directory` names that exact existing root. They are not
|
||||
moved, migrated, or deleted automatically. The frozen serialized identifier
|
||||
`workspace_schema_version` remains part of checkpoint compatibility; it is not
|
||||
a configuration setting.
|
||||
|
||||
For a Linux service account, independently provision:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
checkpoints:
|
||||
enabled: true
|
||||
directory: /var/cache/notarius/checkpoints
|
||||
```
|
||||
|
||||
Remove an exact checkpoint identity directory or the configured root only when
|
||||
recomputation is acceptable.
|
||||
|
||||
### Resume And Selective Recompute
|
||||
|
||||
`--resume` loads compatible accepted work only when checkpoint recording is
|
||||
enabled. A normal resumed run may reuse source, extract, merge, and normalize
|
||||
checkpoints independently and may recompute a stage after a cache miss.
|
||||
Generated references add a dependency fingerprint
|
||||
for the producer's artifact kind, schema identity, media type, canonical
|
||||
content digest, and size. If that fingerprint changes or the producer is
|
||||
missing, dependent checkpoints are invalidated; unrelated work remains eligible
|
||||
for reuse.
|
||||
|
||||
`--recompute-step <step-id>` requires both `--resume` and
|
||||
`cache.checkpoints.enabled: true`. It forces the named step and all transitive
|
||||
dependents to execute, while compatible predecessors and unrelated lanes remain
|
||||
reusable. The ID may be an explicit configured step or `default` for an
|
||||
implicit single-step pipeline. It cannot be combined with `--only`, and it does
|
||||
not change the persistent identity of otherwise identical checkpoints.
|
||||
Decisions are bounded and categorized as `reused`, `executed`,
|
||||
`forced_recompute`, or `dependency_invalidated`.
|
||||
|
||||
For an unselected producer required by a recomputed step, Notarius loads the
|
||||
accepted normalized artifact directly. Valid normalize state is sufficient even
|
||||
when that producer's extract or merge checkpoint is missing or corrupt. The
|
||||
normalize manifest must be successful and match workspace schema v3, the exact
|
||||
current invocation identity, step, lane, and normalizer; its payload digest and
|
||||
canonical codec representation must also validate. A forced producer bypasses
|
||||
this lookup and executes.
|
||||
|
||||
If a required predecessor's accepted normalized artifact is missing, rejected,
|
||||
corrupt, non-canonical, or incompatible, the run fails before the dependent
|
||||
step starts. It does not fall back to rerunning that predecessor. The failure
|
||||
manifest retains completed upstream outcomes and dependency context but not
|
||||
generated reference content. For diagnosis, first check the producer step and
|
||||
lane in the manifest, then inspect checkpoint decision categories and reason
|
||||
codes. Rerun the producer explicitly rather than copying an artifact into the
|
||||
checkpoint root.
|
||||
|
||||
The decision that caused a required-predecessor failure is retained before the
|
||||
run returns, and the CLI error identifies its step, lane, and reason code.
|
||||
|
||||
Checkpoint reason codes are stable diagnostic identifiers:
|
||||
|
||||
| Reason code | Operator meaning |
|
||||
| --- | --- |
|
||||
| `loading_disabled` | This invocation did not enable checkpoint loading. |
|
||||
| `checkpoint_missing` | The requested checkpoint file does not exist. |
|
||||
| `checkpoint_path_invalid` | The requested checkpoint location failed confinement validation. |
|
||||
| `checkpoint_read_failed` | An existing checkpoint could not be read. |
|
||||
| `checkpoint_decode_failed` | Checkpoint JSON could not be decoded. |
|
||||
| `workspace_schema_incompatible` | The stored workspace schema is not supported by this build. |
|
||||
| `identity_mismatch` | The stored invocation identity differs from the current invocation. |
|
||||
| `stage_mismatch`, `step_mismatch`, `lane_mismatch`, `module_mismatch` | Stored scope does not match the requested pipeline scope. |
|
||||
| `status_not_reusable` | The stored operation did not finish in a reusable status. |
|
||||
| `dependency_mismatch` | Stored dependencies differ; the category is `dependency_invalidated`. |
|
||||
| `artifact_payload_invalid` | Stored artifact payload structure or encoding is invalid. |
|
||||
| `artifact_digest_mismatch` | Stored artifact bytes do not match their recorded digest. |
|
||||
| `artifact_codec_incompatible` | Stored artifact identity is incomplete or incompatible with the codec contract. |
|
||||
| `artifact_not_canonical` | The codec can decode the artifact, but its bytes are not canonical. |
|
||||
| `checkpoint_reused` | The stored checkpoint passed validation and was reused. |
|
||||
| `accepted_artifact_reused` | A required producer's accepted normalized artifact was canonically validated and hydrated. |
|
||||
| `recompute_step` | Selective recomputation forced execution of this lane. |
|
||||
|
||||
Decision detail is bounded explanatory text derived from the stable reason code,
|
||||
not caller-supplied prose or a data-recovery channel. It never contains
|
||||
checkpoint paths, artifact or reference content, source content, credentials,
|
||||
or environment values.
|
||||
|
||||
## Debug Bundles
|
||||
|
||||
Only `notarius run --debug` enables debug collection. The selected root contains
|
||||
one retained bundle per invocation:
|
||||
|
||||
```text
|
||||
<debug-root>/<run-id>/
|
||||
summary/
|
||||
trace/
|
||||
```
|
||||
|
||||
`summary/` contains redacted invocation, effective-configuration, resolved
|
||||
pipeline and reference provenance, checkpoint and chunk-plan decisions, run
|
||||
manifest, warnings, report, and any available error text. It excludes raw
|
||||
source, references, annotations, prompts, model responses, credentials, and
|
||||
malformed cache bytes.
|
||||
|
||||
`trace/` contains application-owned execution detail, including source and
|
||||
stage material, plans, chunks, validator attempts, prompts, model responses,
|
||||
timing, and serialized artifacts. It may retain application data omitted from
|
||||
output. Credentials, credential-shaped values, sensitive metadata, unrelated
|
||||
environment values, and unrelated filesystem content are not captured.
|
||||
|
||||
Bundles inherit the sensitivity of the application data they capture. Their
|
||||
additional risk comes from copying and aggregating that data, so restrict
|
||||
access, avoid shared roots between untrusted users, and define retention outside
|
||||
Notarius. Created bundle directories use `0700` and files use `0600` on
|
||||
supported Unix systems.
|
||||
|
||||
Notarius never automatically deletes a requested bundle. If allocation
|
||||
succeeds, its path is reported on success and failure. A requested summary or
|
||||
trace write failure makes the command fail, preserving whatever bundle data was
|
||||
already written for inspection. Every allocated bundle makes one best-effort
|
||||
attempt to record a terminal `run-report.json`.
|
||||
|
||||
## Failures And Warnings
|
||||
|
||||
Failures before debug allocation are reported on stderr without a bundle.
|
||||
Failures after allocation report the bundle path on stderr and make independent
|
||||
attempts to write a failure `run-report.json` and `error.log`. The report retains
|
||||
the paths and pipeline outcome fields known at the failure point. If either
|
||||
terminal write fails, the original command error remains first on stderr,
|
||||
followed by the persistence error and bundle path. An output-write failure
|
||||
leaves the allocated bundle in place. A successful run with warnings exits `0`,
|
||||
reports a warning count on stderr, and records warnings in durable output and
|
||||
any requested debug summary.
|
||||
|
||||
## Cleanup
|
||||
|
||||
It is safe to remove specific old run directories after their output and
|
||||
diagnostics are no longer needed:
|
||||
Use exact paths for manual cleanup. Examples:
|
||||
|
||||
```sh
|
||||
rm -rf /tmp/notarius/run-1234567890
|
||||
rm -rf ./notarius-output/run-1234567890
|
||||
rm -rf ./notarius-output/run-1721300000000000000-0123456789abcdef0123456789abcdef
|
||||
rm -rf /var/cache/notarius/chunk-plans/0123abcd
|
||||
rm -rf /var/cache/notarius/checkpoints/pipeline/input-0123/pipeline-4567/identity-89ab
|
||||
rm -rf ./notarius-debug/run-1721300000000000000-0123456789abcdef0123456789abcdef
|
||||
```
|
||||
|
||||
Use exact run-directory paths. Avoid broad cleanup commands against parent
|
||||
directories unless they are part of your own operational policy.
|
||||
Avoid broad recursive cleanup against a parent root unless it is an explicit
|
||||
operator policy. Output deletion is permanent user-data loss. Cache deletion is
|
||||
recoverable but can repeat expensive work. Debug deletion removes troubleshooting
|
||||
evidence and any retained application-data copy.
|
||||
|
||||
## Operational Limits
|
||||
|
||||
There is no command to resume a failed run. Re-run `notarius run` after fixing
|
||||
the cause.
|
||||
|
||||
Provider retries are limited to the OpenAI-compatible client retry behavior
|
||||
configured by the effective LLM profile. There is no separate CLI retry command.
|
||||
|
||||
Notarius writes local files only. Remote storage and archive management are not
|
||||
part of the implemented CLI.
|
||||
Provider retries and timeouts are handled by Scriptorium according to the
|
||||
selected execution profile. Pipeline module retry settings are defined in
|
||||
[Configuration](config.md#module-bindings). Extract worker concurrency and
|
||||
actual provider-call concurrency are separate limits; their fields and
|
||||
validation are defined in [Configuration](config.md#concurrency). Notarius
|
||||
writes local files only; remote storage and archive management are outside the
|
||||
implemented CLI.
|
||||
|
||||
@@ -1,210 +1,228 @@
|
||||
# Architecture
|
||||
|
||||
This document defines Notarius development policy. It is inward-facing:
|
||||
developers and LLM coding agents should use it to preserve the project's shape,
|
||||
boundaries, and invariants as the code evolves.
|
||||
This document defines the intended high-level architecture of Notarius and the
|
||||
invariants that changes must preserve. Implemented component details belong in
|
||||
[Internal Overview](../internal/overview.md) and its linked documents. The
|
||||
reasoning behind significant architectural choices belongs in
|
||||
[ADRs](../adr/).
|
||||
|
||||
Keep this document concise. It should describe durable architectural rules, not
|
||||
CLI syntax, configuration reference material, module catalogs, or roadmap items.
|
||||
## System Shape
|
||||
|
||||
## Project Shape
|
||||
Notarius is a small, dependency-light Go application for extracting structured
|
||||
artifacts from source material. It is a general extraction platform whose
|
||||
source formats, extraction domains, validation policies, LLM providers, and
|
||||
output formats are isolated behind explicit boundaries.
|
||||
|
||||
Notarius is a small, explicit, dependency-light Go application for extracting
|
||||
structured artifacts from source material using modular pipeline stages.
|
||||
|
||||
The application is contract-first but not abstraction-heavy. Add interfaces and
|
||||
extension points when they protect a real boundary:
|
||||
|
||||
- external source formats;
|
||||
- pipeline stage modules;
|
||||
- validators;
|
||||
- LLM providers and runtime plumbing;
|
||||
- output schemas and embedded assets.
|
||||
|
||||
Avoid abstractions that only anticipate hypothetical complexity. Prefer narrow
|
||||
contracts that can be exercised by tests and real modules.
|
||||
|
||||
## Core Invariants
|
||||
|
||||
The framework must remain source-agnostic and domain-agnostic.
|
||||
|
||||
Source-format details belong in input modules. Transcript-specific concepts such
|
||||
as segments, speakers, timestamps, and transcript schemas must not spread into
|
||||
runner, extractor, validator, or LLM framework code.
|
||||
|
||||
Extraction-domain details belong in domain modules. D&D-specific concepts such
|
||||
as spells, NPCs, items, combat turns, and encounters must not spread into core
|
||||
source, runner, or LLM framework packages.
|
||||
|
||||
Extracted facts should be grounded with source references. Source references
|
||||
should point to generic source units, not transcript-only structures. Framework
|
||||
code should preserve source-reference ranges exactly and should not merge or
|
||||
rewrite overlapping ranges unless a module explicitly owns that behavior.
|
||||
|
||||
The application workflow is fixed:
|
||||
The application has one fixed pipeline shape:
|
||||
|
||||
```text
|
||||
input -> chunk -> extract -> merge -> normalize -> output
|
||||
```
|
||||
|
||||
These stages should remain explicit in the architecture. Chunking, merging, and
|
||||
normalization must not be hidden inside domain extractors when they represent
|
||||
general pipeline behavior.
|
||||
Pipelines are configured compositions of this shape. They are not arbitrary
|
||||
DAGs or a general workflow language. Every stage remains explicit; general
|
||||
chunking, merging, or normalization behavior must not be hidden inside an
|
||||
extractor.
|
||||
|
||||
Pipelines are fixed-shape templates for this workflow, not arbitrary DAGs or a
|
||||
general workflow language. Module selection should be configuration- and
|
||||
registry-driven, not scattered through conditionals.
|
||||
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.
|
||||
|
||||
## Package Boundaries
|
||||
Notarius is contract-first without being abstraction-heavy. Interfaces and
|
||||
extension points should protect demonstrated boundaries. New abstraction is not
|
||||
itself an architectural goal.
|
||||
|
||||
Prefer fewer, larger framework packages until a boundary proves itself through
|
||||
import direction, ownership, test seams, or substantial file size.
|
||||
## Layers And Dependency Direction
|
||||
|
||||
Core packages should contain deterministic models and policy. Framework
|
||||
packages should contain reusable orchestration and provider plumbing. Concrete
|
||||
business logic should live under stage-oriented module packages:
|
||||
The application boundary is the composition root and may depend on concrete
|
||||
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.
|
||||
|
||||
```text
|
||||
internal/modules/input/...
|
||||
internal/modules/chunk/...
|
||||
internal/modules/extract/...
|
||||
internal/modules/merge/...
|
||||
internal/modules/normalize/...
|
||||
internal/modules/output/...
|
||||
```
|
||||
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
|
||||
[Internal Overview](../internal/overview.md).
|
||||
|
||||
Use short, lowercase, idiomatic Go package names. Avoid package names that repeat
|
||||
parent-stage context.
|
||||
The following dependency boundaries are mandatory:
|
||||
|
||||
Input modules translate external source formats into the core source model.
|
||||
They may know about external schema details, source-specific metadata, and
|
||||
format-specific validation rules. They should not own extraction-domain
|
||||
decisions.
|
||||
- extractors and validators do not depend on concrete input adapters;
|
||||
- provider-specific types do not cross the LLM runtime boundary;
|
||||
- external dependency types do not leak across internal package boundaries
|
||||
unless that dependency is the package's explicit contract.
|
||||
|
||||
Extract modules own artifact semantics, prompt usage, structured response schema
|
||||
selection, validator defaults, and domain-specific interpretation. They should
|
||||
depend on framework contracts and core source/artifact types, not concrete input
|
||||
module packages.
|
||||
Shared helpers may support demonstrated common needs, but must not move
|
||||
source-format or extraction-domain knowledge into generic framework packages.
|
||||
External dependencies require a clear correctness, security, interoperability,
|
||||
or complexity benefit.
|
||||
|
||||
Merge modules combine extracted candidates. Normalize modules reconcile merged
|
||||
candidates for semantic consistency. Generic behavior may exist for simple
|
||||
artifact types, but domain-specific behavior belongs in modules for the relevant
|
||||
stage.
|
||||
## Source And Domain Boundaries
|
||||
|
||||
Output modules serialize final artifacts and may report warnings out of band.
|
||||
CLI, diagnostics, and reporting layers are responsible for surfacing those
|
||||
warnings.
|
||||
Input modules translate external source formats into the generic source model.
|
||||
Format-specific schemas, fields, and validation remain with the input module
|
||||
and its integration contract.
|
||||
|
||||
Framework stages operate on source documents, source units, and source
|
||||
references rather than format-specific structures. A source reference identifies
|
||||
an ordered range of generic source units. Framework code preserves those ranges
|
||||
and does not merge or rewrite them unless a stage module explicitly owns that
|
||||
behavior. Every source unit carries a validated self-reference to its containing
|
||||
document and its own unit ID.
|
||||
|
||||
Extract modules own artifact semantics, prompt use, response schemas, and
|
||||
domain interpretation. Domain-specific concepts remain in the relevant module,
|
||||
validator, shared domain helper, and artifact contract.
|
||||
|
||||
Typed artifact registrations declare one stable artifact kind and exact Go
|
||||
type from extraction through merge, normalization, and semantic validation.
|
||||
Pipeline resolution requires a compatible codec and matching kind-specific
|
||||
variants before a typed lane can be accepted. Framework-owned erasure remains
|
||||
private and must report type incompatibility as an error rather than a panic.
|
||||
|
||||
Auxiliary references provide context or disambiguation. They are not source
|
||||
evidence and must not be converted into source references.
|
||||
|
||||
## Pipeline Composition And Ownership
|
||||
|
||||
Module selection is configuration- and registry-driven. The framework resolves
|
||||
named pipeline definitions, applies explicit defaults and runtime overrides,
|
||||
and verifies module availability and capabilities before execution. Structural
|
||||
pipeline choices must not be scattered through conditionals or hidden behind
|
||||
ad hoc command flags.
|
||||
|
||||
Resolution validates every selected module and validator option set. A separate
|
||||
preparation boundary then constructs the complete input, chunk, lane,
|
||||
validation, and output implementation set in pipeline order. The runner accepts
|
||||
only that prepared set, so construction and dependency failures occur before
|
||||
source parsing or any other module operation.
|
||||
|
||||
Stage ownership is explicit:
|
||||
|
||||
- input modules convert external material into the generic source model;
|
||||
- chunk modules partition source material for extraction;
|
||||
- extract modules produce domain artifacts from chunks;
|
||||
- merge modules combine accepted extraction outputs;
|
||||
- normalize modules reconcile merged output;
|
||||
- output modules encode accepted results and run outcomes into logical files.
|
||||
|
||||
Chunk modules produce source-addressed chunk plans rather than materialized
|
||||
chunks. The framework validates and materializes those plans into the generic
|
||||
chunk representation before chunk validation and lane execution. Plan reuse is
|
||||
therefore independent of the configured pipeline, module options, references,
|
||||
lanes, validators, and LLM profile: the canonical source digest selects the
|
||||
plan, while the current run still applies its configured chunk validators to
|
||||
the materialized chunks.
|
||||
|
||||
The framework owns orchestration and handoff provenance. Modules return logical
|
||||
results and warnings; they do not own CLI reporting, physical output, cache, or
|
||||
debug roots, durable file placement, or checkpoint and debug lifecycle.
|
||||
|
||||
After pipeline-wide chunking, extraction uses bounded framework concurrency.
|
||||
One run-wide worker pool receives chunk-scoped lane jobs in deterministic
|
||||
chunk-first, lane-second order. A lane may begin its merge and normalize
|
||||
continuation only after all of its extract jobs are terminal; that continuation
|
||||
remains serial within the lane, while bounded continuations for different lanes
|
||||
may overlap. The framework must not create unbounded goroutines per lane or
|
||||
chunk.
|
||||
|
||||
Completion timing does not choose public ordering or errors. The coordinator
|
||||
orders accepted artifacts, warnings, rejections, checkpoint events, and
|
||||
framework errors by stable pipeline scope. Rejections do not cancel unrelated
|
||||
work. A framework error cancels derived work, prevents undispatched work from
|
||||
starting, waits for started work, and prevents output encoding.
|
||||
|
||||
## Validation
|
||||
|
||||
Validators should be independently testable and composable.
|
||||
Validation is a framework-managed boundary around outputs from chunk, extract,
|
||||
merge, and normalize stages. Validators receive immutable stage output
|
||||
and make an explicit whole-output decision: approve, approve with warnings, or
|
||||
reject.
|
||||
|
||||
Deterministic validators should run before LLM-backed validators when both are
|
||||
present. Validator decision semantics should be explicit: each candidate
|
||||
artifact evaluated by a validator should receive exactly one decision from that
|
||||
validator.
|
||||
Typed artifact validators receive the domain value directly. Chunk validators
|
||||
receive source-zone chunks, while serialized validators receive immutable
|
||||
representation bytes and declared schema metadata. A validator registered for
|
||||
one target or artifact kind cannot satisfy an incompatible selection.
|
||||
|
||||
LLM-backed review belongs in module-owned validator chains, not in an implicit
|
||||
global review phase. Extract and normalize modules may both use deterministic
|
||||
and LLM-backed validators.
|
||||
Rejection is a recorded pipeline outcome, not a framework execution error.
|
||||
Validator execution failures are framework errors. Rejected output does not
|
||||
advance to the next stage.
|
||||
|
||||
Shared validator runtime mechanics belong in framework code. Concrete validator
|
||||
behavior belongs in module or validator implementation packages.
|
||||
Default validator chains are production composition policy and are registered
|
||||
centrally by stage and module. Configuration may replace a stage-local default,
|
||||
including with an explicitly empty chain. Configured validator order is
|
||||
authoritative; the framework must not silently reorder it.
|
||||
|
||||
## LLM Runtime
|
||||
## LLM Boundary
|
||||
|
||||
LLM provider details belong behind transport-neutral framework contracts.
|
||||
Modules and validators use transport-neutral structured completion contracts.
|
||||
Provider request and response types, authentication, transport behavior, and
|
||||
provider error adaptation remain inside the LLM runtime.
|
||||
|
||||
Provider-specific HTTP request and response types should stay inside the LLM
|
||||
runtime package. Prompt construction should stay in extractors, validators, or
|
||||
shared prompt helpers; provider adapters should not own domain prompt logic.
|
||||
The caller of the LLM owns prompt selection, prompt inputs, response schema,
|
||||
and interpretation of structured output. Provider adapters do not own source-
|
||||
or domain-specific prompt logic.
|
||||
|
||||
Errors, diagnostics, reports, manifests, and redacted configuration must not
|
||||
expose secrets.
|
||||
LLM calls and other external operations accept cancellation and respect
|
||||
timeouts. Concurrency control belongs in shared runtime plumbing rather than in
|
||||
individual modules.
|
||||
|
||||
## Configuration
|
||||
The application-wide LLM scheduler bounds actual provider calls independently
|
||||
of framework worker limits. Every LLM-backed module, retry, and validator uses
|
||||
the single injected scheduled client, including work performed by overlapping
|
||||
lanes.
|
||||
|
||||
Configuration should make pipeline composition explicit and discoverable.
|
||||
## Configuration And Provenance
|
||||
|
||||
Centralize configuration loading, precedence, defaults, and validation. Structural
|
||||
pipeline choices should come from named pipeline definitions, not ad hoc command
|
||||
flags. Operational overrides may be handled separately when they do not obscure
|
||||
the configured pipeline structure.
|
||||
Configuration loading, precedence, defaults, environment overrides, redaction,
|
||||
and validation are centralized. Named pipeline definitions make structural
|
||||
composition explicit and discoverable. Operational overrides are permitted
|
||||
when they do not obscure the configured pipeline structure.
|
||||
|
||||
Module registries should expose module metadata and capabilities without
|
||||
requiring module construction. Configuration validation should fail fast when a
|
||||
pipeline binds incompatible or unknown modules.
|
||||
Run preparation fails before stage execution when statically discoverable
|
||||
modules, capabilities, reference bindings, or explicitly selected profiles are
|
||||
invalid or incompatible.
|
||||
|
||||
Run manifests should record enough resolved pipeline provenance to make a run
|
||||
auditable after named configuration changes over time.
|
||||
Run manifests record enough resolved pipeline, module, source, reference, and
|
||||
LLM provenance to make a run auditable after configuration changes. Manifests
|
||||
record identities and summaries rather than secret or large payload content.
|
||||
|
||||
## Dependencies
|
||||
## State, Output, And Safety
|
||||
|
||||
Prefer the Go standard library where practical.
|
||||
Notarius exposes three filesystem surfaces with independent roots and
|
||||
lifecycle:
|
||||
|
||||
Use external dependencies only when justified by correctness, security,
|
||||
interoperability, or substantial complexity reduction. Good reasons include
|
||||
widely used file formats, complex validation behavior, or secure transport
|
||||
handling.
|
||||
- output is durable user data; output modules define logical files and the CLI
|
||||
owns their placement;
|
||||
- cache is reconstructible state, with separate chunk-plan and checkpoint
|
||||
families; and
|
||||
- debug is explicitly requested inspection data, combining a redacted summary
|
||||
with a detailed trace.
|
||||
|
||||
Avoid dependencies for small conveniences. Do not let external dependency types
|
||||
leak across internal package boundaries unless the dependency is itself the
|
||||
explicit contract of that package.
|
||||
Chunk plans are keyed only by canonical source digest. Configured checkpoint
|
||||
recording is independent of checkpoint reuse; checkpoints are loaded only for
|
||||
an invocation that explicitly requests resume. Debug is never a cache input and
|
||||
is never created without an explicit request. Pipeline modules receive
|
||||
collaborator interfaces and never physical roots.
|
||||
|
||||
## State, Files, and Safety
|
||||
Writes are atomic where practical. Paths for writes, moves, overwrites, and
|
||||
deletion must be narrow and explicit. Notarius never automatically deletes
|
||||
output or requested debug bundles; cache cleanup is explicit and recoverable.
|
||||
|
||||
If the application writes durable state, writes should be atomic where
|
||||
practical. Multi-step workflows should preserve enough diagnostics to support
|
||||
inspection after failure.
|
||||
Secrets must not appear in errors, logs, output, cache, debug summaries,
|
||||
traces, manifests, documentation, examples, or redacted configuration. Debug
|
||||
collection is allowlisted to application-owned payloads and must not capture
|
||||
unrelated process environment values or filesystem content. Trace data may
|
||||
contain application data and therefore inherits its sensitivity; operators own
|
||||
access controls and retention. Physical layout and operation are defined in
|
||||
[Operations](../operations.md).
|
||||
|
||||
Code that deletes, moves, or overwrites files must use narrow, explicit paths.
|
||||
Avoid broad parent-directory operations. Cleanup that can cause data loss must
|
||||
be opt-in.
|
||||
## Architectural Non-Goals
|
||||
|
||||
## Errors and Logging
|
||||
Notarius does not aim to provide:
|
||||
|
||||
Errors should be actionable and preserve context. Wrap errors with operation and
|
||||
path or resource context. CLI code should convert internal errors into concise
|
||||
user-facing messages.
|
||||
|
||||
Errors and logs must not expose secrets. Logs should describe operations,
|
||||
external calls, retries, and failure causes, but should not include large source
|
||||
or artifact payloads by default.
|
||||
|
||||
Long-running operations should accept `context.Context`. External calls,
|
||||
subprocesses, HTTP requests, storage operations, LLM calls, and multi-stage
|
||||
workflows should respect cancellation and timeouts.
|
||||
|
||||
## Testing
|
||||
|
||||
Core logic should be testable without real external services. Use fakes,
|
||||
fixtures, or local test doubles for input modules, extract modules, validators,
|
||||
and LLM clients where practical.
|
||||
|
||||
Contract-first work should include fake implementations that prove interfaces
|
||||
compose before real modules depend on them.
|
||||
|
||||
Maintain a fixture-driven walking skeleton that exercises the full pipeline with
|
||||
fake modules and fake external clients. This protects stage composition as real
|
||||
modules evolve.
|
||||
|
||||
Important CLI and configuration workflows should have tests. Adapter, extractor,
|
||||
validator, and stage contracts should have focused tests that do not require
|
||||
running the full application unless end-to-end coverage is intentional.
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation should follow the project documentation policy. Keep user docs
|
||||
focused on implemented behavior. Put future, planned, or aspirational work only
|
||||
under `docs/roadmap/`.
|
||||
|
||||
Core documentation should use generic terms such as source document, source
|
||||
unit, source reference, input adapter, extractor, chunker, merger, normalizer,
|
||||
artifact, validator, and run manifest.
|
||||
|
||||
Source-format details belong in input module or integration docs.
|
||||
Domain-specific extraction details belong in extract module or artifact docs.
|
||||
|
||||
When changing architecture, config, CLI behavior, stage modules, extractor
|
||||
contracts, validator contracts, LLM runtime behavior, or artifact schemas, update
|
||||
the relevant docs and examples in the same change.
|
||||
- an arbitrary workflow graph or general workflow language;
|
||||
- source-format or extraction-domain behavior in generic framework packages;
|
||||
- provider-specific contracts exposed to modules;
|
||||
- structural pipeline composition through ad hoc CLI flags;
|
||||
- implicit cross-stage behavior that bypasses the fixed pipeline;
|
||||
- abstractions introduced solely for hypothetical future complexity.
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
# Development
|
||||
|
||||
This document defines contributor workflow for Notarius. For architectural
|
||||
invariants and package boundaries, read [Architecture](architecture.md) first.
|
||||
|
||||
## Required Reading
|
||||
|
||||
Before changing the repository, review:
|
||||
|
||||
- [Architecture](architecture.md)
|
||||
- [Documentation Policy](documentation.md)
|
||||
|
||||
Keep current-behavior documentation limited to implemented behavior. Put planned
|
||||
or deferred behavior under `docs/roadmap/`.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
- `cmd/notarius`: executable entry point.
|
||||
- `internal/cli`: CLI parsing, production catalog wiring, config loading, run
|
||||
command orchestration, output writes, and user-facing errors.
|
||||
- `internal/core`: deterministic models and policy for artifacts, source
|
||||
documents, config, and diagnostics.
|
||||
- `internal/framework`: reusable contracts, pipeline orchestration, prompt
|
||||
helpers, validation helpers, and LLM runtime plumbing.
|
||||
- `internal/modules`: concrete input, chunk, extract, merge, normalize, and
|
||||
output modules.
|
||||
- `docs`: policy, user/operator docs, internal docs, integration docs, and
|
||||
roadmap files.
|
||||
- `examples`: maintained, secret-free examples covered by tests where practical.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
Run focused tests for the area changed, then run the broader checks when the
|
||||
change affects shared contracts, CLI behavior, or documentation examples.
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./cmd/notarius
|
||||
```
|
||||
|
||||
Useful focused checks:
|
||||
|
||||
```sh
|
||||
go test ./internal/cli
|
||||
go test ./internal/core/config
|
||||
go test ./internal/framework/pipeline
|
||||
go test ./internal/framework/llm
|
||||
go test ./internal/modules/input/seriatim
|
||||
go test ./internal/modules/extract/dnd/spells
|
||||
go test ./internal/modules/output/json
|
||||
```
|
||||
|
||||
## Go Conventions
|
||||
|
||||
- Prefer the standard library unless a dependency is justified by correctness,
|
||||
security, interoperability, or substantial complexity reduction.
|
||||
- Keep package names short, lowercase, and idiomatic.
|
||||
- Preserve import direction: framework and core code must not depend on concrete
|
||||
production modules.
|
||||
- Use `context.Context` for long-running operations and external calls.
|
||||
- Return contextual errors that name the operation and relevant module, path, or
|
||||
resource.
|
||||
- Do not include secrets in errors, logs, diagnostics, manifests, or docs.
|
||||
|
||||
## Adding Config Fields
|
||||
|
||||
Config behavior is centralized under `internal/core/config`.
|
||||
|
||||
When adding a file config field:
|
||||
|
||||
1. Update file config structs and YAML parsing in `file_config.go`.
|
||||
2. Apply the field over defaults in config application code.
|
||||
3. Add validation in `validation.go` when the field has constraints.
|
||||
4. Add environment override support in `env.go` only for operational overrides.
|
||||
5. Update redaction if the field can contain secrets.
|
||||
6. Add focused config tests.
|
||||
7. Update [Configuration](../config.md) and maintained examples when behavior
|
||||
changes.
|
||||
|
||||
Pipeline composition should remain config-driven. Do not add command flags that
|
||||
silently replace structural pipeline definitions.
|
||||
|
||||
## Adding CLI Flags Or Commands
|
||||
|
||||
CLI behavior lives in `internal/cli`.
|
||||
|
||||
When adding CLI surface:
|
||||
|
||||
1. Keep syntax explicit and update usage text.
|
||||
2. Validate arguments before running expensive work.
|
||||
3. Convert internal errors into concise user-facing messages.
|
||||
4. Add CLI tests for success, syntax errors, and failure modes.
|
||||
5. Update [CLI Reference](../cli.md), and update
|
||||
[Operations](../operations.md) or [Troubleshooting](../troubleshooting.md)
|
||||
if run behavior changes.
|
||||
|
||||
## Adding Modules Or Adapters
|
||||
|
||||
Concrete modules live under `internal/modules/<kind>/...` and implement the
|
||||
interfaces in `internal/framework/contracts`.
|
||||
|
||||
For a new production module:
|
||||
|
||||
1. Implement the relevant contract.
|
||||
2. Expose a `ModuleSpec` with the correct module key, module kind, provided
|
||||
capabilities, and required capabilities.
|
||||
3. Expose a `Register` function that registers the module with its registry.
|
||||
4. Add focused module tests for contract behavior, registration, options,
|
||||
validation, and errors.
|
||||
5. Register the module in `internal/cli/catalog.go` only when it is production
|
||||
ready.
|
||||
6. Update internal docs and user-facing docs only for implemented behavior.
|
||||
|
||||
Source-format behavior belongs in input modules and integration docs.
|
||||
Extraction-domain behavior belongs in extract modules and artifact docs.
|
||||
|
||||
## Updating Examples
|
||||
|
||||
Examples must be valid, secret-free, and small.
|
||||
|
||||
- Prefer environment-based secret configuration.
|
||||
- Keep `examples/dnd-spells.config.yml` loadable by CLI tests.
|
||||
- Keep `examples/seriatim-minimal-transcript.json` compatible with the Seriatim
|
||||
adapter.
|
||||
- Do not add expected-output fixtures unless they are validated or have a clear
|
||||
regeneration procedure.
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
Update docs in the same change when behavior changes.
|
||||
|
||||
- CLI syntax: `docs/cli.md`
|
||||
- Config fields and defaults: `docs/config.md`
|
||||
- Output, diagnostics, retention, or recovery: `docs/operations.md`
|
||||
- Common user-facing failures: `docs/troubleshooting.md`
|
||||
- Internal architecture and contracts: `docs/internal/`
|
||||
- External file formats and durable integration contracts: `docs/integrations/`
|
||||
- Future or planned work only: `docs/roadmap/`
|
||||
@@ -1,446 +1,144 @@
|
||||
# Go Project Documentation Policy
|
||||
# Documentation Policy
|
||||
|
||||
## Purpose
|
||||
|
||||
Project documentation must help five audiences:
|
||||
|
||||
1. users who need to run the application;
|
||||
2. administrators/operators who need to configure and operate it;
|
||||
3. developers who need to understand and change it safely;
|
||||
4. LLM coding agents that need clear scope, boundaries, and invariants;
|
||||
5. developers and LLM coding agents integrating this project from another codebase.
|
||||
|
||||
Docs should be accurate, concise, task-oriented, and organized by audience. Prefer links to canonical docs over repetition.
|
||||
This policy assigns each documentation topic to one canonical owner. Its goal is
|
||||
to keep Notarius documentation accurate, concise, discoverable, and resistant
|
||||
to drift for users, operators, developers, integrators, and LLM coding agents.
|
||||
|
||||
## Core Rules
|
||||
|
||||
### 1. Keep docs concise
|
||||
### One Canonical Owner
|
||||
|
||||
Each document should cover a defined scope and only the essentials for that scope.
|
||||
Each authoritative fact belongs in one document. A non-owning document may give
|
||||
a short, stable summary for orientation, but it must link to the canonical owner
|
||||
instead of repeating volatile details.
|
||||
|
||||
Avoid:
|
||||
- long background explanations;
|
||||
- repeated reference material;
|
||||
- implementation detail in user-facing docs;
|
||||
- aspirational language outside roadmap docs;
|
||||
- verbose examples where one minimal example is clearer.
|
||||
Volatile details include commands, flags, configuration fields and defaults,
|
||||
module keys, schemas, file names, paths, status codes, retry behavior, and
|
||||
runtime guarantees. If readers could reasonably treat a statement as a
|
||||
contract, maintain it only in the owning document.
|
||||
|
||||
### 2. Document only implemented behavior outside roadmap files
|
||||
### Current And Future Behavior
|
||||
|
||||
Outside `docs/roadmap/`, documentation describes implemented behavior only.
|
||||
Partial features may be described only to their implemented boundary.
|
||||
|
||||
ADRs are the narrow exception: an ADR may record an accepted architectural
|
||||
decision before implementation, but acceptance must not be presented as proof
|
||||
that the behavior exists. The roadmap owns implementation status and sequencing
|
||||
until the decision is implemented. Current architecture, user, operator,
|
||||
integration, and internal documentation are updated when the behavior lands.
|
||||
|
||||
Unimplemented, planned, aspirational, experimental, or future work may be described only under:
|
||||
### Audience And Detail
|
||||
|
||||
Write for the document's stated audience and include only the detail needed for
|
||||
its owned topic. User and operator docs should not expose implementation detail.
|
||||
Developer docs should link to user-facing and external contracts rather than
|
||||
restate them.
|
||||
|
||||
- `docs/roadmap/`
|
||||
### Examples
|
||||
|
||||
No other documentation file, including `README.md`, should describe code, features, modules, stages, commands, config fields, or behaviors that do not currently exist.
|
||||
|
||||
If a feature is partial, non-roadmap docs may describe only the implemented portion and its current boundary.
|
||||
|
||||
### 3. Use canonical homes
|
||||
|
||||
Each type of information should have one canonical location.
|
||||
|
||||
Canonical homes:
|
||||
|
||||
- project purpose and quickstart: `README.md`
|
||||
- development principles: `docs/policy/architecture.md`
|
||||
- public HTTP API reference: `docs/api.md`
|
||||
- configuration reference: `docs/config.md`
|
||||
- CLI reference: `docs/cli.md`
|
||||
- operations and recovery: `docs/operations.md`
|
||||
- troubleshooting: `docs/troubleshooting.md`
|
||||
- public API/package consumer guidance: `docs/consumers/`
|
||||
- implemented internals: `docs/internal/`
|
||||
- external protocol, service, and file-format contracts: `docs/integrations/`
|
||||
- future work: `docs/roadmap/`
|
||||
- contributor workflow: `docs/policy/development.md`
|
||||
- copyable examples: `examples/`
|
||||
|
||||
Other files should summarize briefly and link to the canonical source.
|
||||
|
||||
### 4. Keep examples real
|
||||
|
||||
Examples should be valid, maintained, and free of secrets.
|
||||
|
||||
Where practical:
|
||||
- example configs should load successfully;
|
||||
- example commands should match real CLI syntax;
|
||||
- important examples should be covered by tests.
|
||||
|
||||
## Documentation Profiles
|
||||
|
||||
All projects require:
|
||||
|
||||
- `README.md`
|
||||
- `docs/policy/architecture.md`
|
||||
|
||||
Additional docs depend on the project.
|
||||
|
||||
### Small library
|
||||
|
||||
Recommended:
|
||||
- `docs/policy/development.md`, if contributor conventions are non-obvious
|
||||
|
||||
### Simple CLI
|
||||
|
||||
Required:
|
||||
- `docs/cli.md`
|
||||
|
||||
Recommended:
|
||||
- `docs/policy/development.md`
|
||||
|
||||
### Config-driven CLI
|
||||
|
||||
Required:
|
||||
- `docs/cli.md`
|
||||
- `docs/config.md`
|
||||
|
||||
Recommended:
|
||||
- `examples/`
|
||||
- `docs/policy/development.md`
|
||||
|
||||
### Stateful or operator-facing application
|
||||
|
||||
Required:
|
||||
- `docs/cli.md`, if CLI-based
|
||||
- `docs/config.md`, if config-driven
|
||||
- `docs/operations.md`
|
||||
|
||||
Recommended:
|
||||
- `docs/troubleshooting.md`
|
||||
- `examples/`
|
||||
- `docs/policy/development.md`
|
||||
|
||||
### Modular, service-oriented, or orchestration application
|
||||
|
||||
Required:
|
||||
- `docs/cli.md`, if CLI-based
|
||||
- `docs/config.md`, if config-driven
|
||||
- `docs/operations.md`
|
||||
- `docs/internal/`
|
||||
- `docs/policy/development.md`
|
||||
|
||||
Recommended:
|
||||
- `docs/troubleshooting.md`
|
||||
- validated examples under `examples/`
|
||||
|
||||
### Public HTTP API service
|
||||
|
||||
Required:
|
||||
- `docs/api.md`
|
||||
- `docs/cli.md`, if CLI-based
|
||||
- `docs/config.md`, if config-driven
|
||||
- `docs/operations.md`
|
||||
- `docs/internal/`
|
||||
- `docs/policy/development.md`
|
||||
|
||||
Recommended:
|
||||
- `docs/troubleshooting.md`
|
||||
- `docs/consumers/`, for task-oriented client integration guides
|
||||
- `docs/integrations/`, for upstream/downstream service contracts
|
||||
- validated examples under `examples/`
|
||||
|
||||
### Project with public packages or consumer APIs
|
||||
|
||||
Required:
|
||||
- `docs/consumers/api.md`
|
||||
- one `docs/consumers/pkg-<name>.md` file per public package, if public packages exist
|
||||
|
||||
Recommended:
|
||||
- copyable consumer examples under `examples/`, if practical
|
||||
|
||||
## Required Documents
|
||||
|
||||
### README.md
|
||||
|
||||
**Audience:** users, administrators, operators
|
||||
|
||||
The README is the outward-facing project orientation page.
|
||||
|
||||
It should include, in order:
|
||||
|
||||
1. concise description;
|
||||
2. elevator pitch;
|
||||
3. shortest useful command or usage example;
|
||||
4. links to targeted docs.
|
||||
|
||||
The README should be short. It is not a manual.
|
||||
|
||||
The “shortest useful command” means the simplest command that performs the project’s core use case. (It does not mean `app --help`.)
|
||||
|
||||
### docs/policy/architecture.md
|
||||
|
||||
**Audience:** developers, LLM coding agents
|
||||
|
||||
`docs/policy/architecture.md` is required for every project.
|
||||
|
||||
It is an inward-facing development policy document. It should describe how the project is intended to be built and changed.
|
||||
|
||||
It should include:
|
||||
|
||||
- project shape;
|
||||
- core design principles;
|
||||
- package and boundary philosophy;
|
||||
- state/persistence philosophy, if applicable;
|
||||
- external integration philosophy, if applicable;
|
||||
- error-handling and logging principles;
|
||||
- testing expectations;
|
||||
- documentation expectations;
|
||||
- architectural invariants;
|
||||
- explicit non-goals, if useful.
|
||||
|
||||
Notably, this file should prescribe a core development *policy* that should remain unchanged as the application evolves. It is not a place for details (e.g., CLI flags) that could change over time.
|
||||
|
||||
The contents of `architecture.md` should be trim and concise. LLMs may be directed to review it routinely via AGENTS.md, CLAUDE.md, or similar.
|
||||
|
||||
### docs/api.md
|
||||
|
||||
**Audience:** external HTTP API consumers, developers, LLM coding agents integrating by HTTP
|
||||
|
||||
Required for projects whose primary public interface is HTTP.
|
||||
|
||||
`docs/api.md` is the canonical public HTTP API contract. It should be normative for external consumers and should not be duplicated by README, operations docs, consumer guides, or integration docs.
|
||||
|
||||
It should include:
|
||||
|
||||
1. base URL conventions;
|
||||
2. authentication and authorization behavior, if implemented;
|
||||
3. response envelope;
|
||||
4. supported media types and content negotiation behavior;
|
||||
5. shared query parameters;
|
||||
6. endpoint reference grouped by route family;
|
||||
7. request parameters and validation rules;
|
||||
8. response fields, units, nullability, and optionality;
|
||||
9. error response shape and status codes;
|
||||
10. pagination, caching, rate-limit, idempotency, and retry behavior, if implemented;
|
||||
11. compact request and response examples.
|
||||
|
||||
It must document only implemented endpoints and behavior. Planned endpoints, proposed fields, future filters, and experimental response shapes belong only under `docs/roadmap/`.
|
||||
|
||||
For HTTP API projects, `docs/consumers/` may provide task-oriented client integration guides, but those guides should link to `docs/api.md` for the authoritative endpoint contract.
|
||||
|
||||
### docs/policy/development.md
|
||||
|
||||
**Audience:** developers, LLM coding agents
|
||||
|
||||
Required for projects maintained by humans and LLM coding agents.
|
||||
|
||||
It should include:
|
||||
|
||||
- repository layout;
|
||||
- build/test commands;
|
||||
- coding conventions;
|
||||
- dependency policy;
|
||||
- how to add config fields;
|
||||
- how to add CLI flags;
|
||||
- how to add modules or adapters, if applicable;
|
||||
- how to update examples;
|
||||
- documentation update expectations.
|
||||
|
||||
### docs/config.md
|
||||
|
||||
**Audience:** administrators, operators, advanced users
|
||||
|
||||
Required for applications with configuration files.
|
||||
|
||||
It should include, in order:
|
||||
|
||||
1. config file locations and discovery precedence;
|
||||
2. minimal working config;
|
||||
3. production-oriented config;
|
||||
4. full configuration reference;
|
||||
5. secrets handling, if applicable;
|
||||
6. links to maintained examples.
|
||||
|
||||
The full configuration reference should be canonical.
|
||||
|
||||
### docs/cli.md
|
||||
|
||||
**Audience:** users, administrators, operators
|
||||
|
||||
Required for CLI applications.
|
||||
|
||||
It should include, in order:
|
||||
|
||||
1. shortest useful command;
|
||||
2. command overview;
|
||||
3. complete flag reference;
|
||||
4. common workflows;
|
||||
5. diagnostic or recovery commands, if applicable.
|
||||
|
||||
Explain when commands are useful, not just their syntax.
|
||||
|
||||
### docs/operations.md
|
||||
|
||||
**Audience:** administrators, operators
|
||||
|
||||
Required for applications that maintain state, support resume behavior, run multi-step workflows, write durable artifacts, use remote storage, or require recovery procedures.
|
||||
|
||||
It should cover:
|
||||
|
||||
- normal workflow;
|
||||
- filesystem layout;
|
||||
- remote storage layout, if applicable;
|
||||
- logs and manifests;
|
||||
- resume/retry behavior;
|
||||
- cleanup behavior;
|
||||
- archive/backup behavior;
|
||||
- safe recovery procedures;
|
||||
- operational caveats.
|
||||
|
||||
### docs/troubleshooting.md
|
||||
|
||||
**Audience:** administrators, operators
|
||||
|
||||
Recommended once recurring failure modes exist.
|
||||
|
||||
Each entry should include:
|
||||
|
||||
- symptom;
|
||||
- likely cause;
|
||||
- diagnostic command or inspection step;
|
||||
- safe fix;
|
||||
- relevant links.
|
||||
|
||||
### docs/consumers/
|
||||
|
||||
**Audience:** developers and LLM coding agents integrating this project from another codebase
|
||||
|
||||
Required for projects with public packages, SDKs, client APIs, plugin APIs, or other application-facing integration surfaces.
|
||||
|
||||
This directory describes how an external codebase should consume the project's public API. It should be task-oriented and copyable where useful. It is not the place for internal implementation details or operator procedures.
|
||||
|
||||
For projects whose public API is HTTP, `docs/consumers/` is not required, and it should not duplicate the endpoint reference in `docs/api.md`. If present, it may provide practical integration workflows, client-specific examples, or migration notes that link back to `docs/api.md`.
|
||||
|
||||
`docs/consumers/api.md` should provide the consumer-facing overview and primary implementation workflow. It should include:
|
||||
|
||||
1. intended consumer audience and use cases;
|
||||
2. required inputs supplied by operators or deployment configuration;
|
||||
3. recommended public package or API workflow;
|
||||
4. minimal copyable example;
|
||||
5. consumer responsibilities and boundaries;
|
||||
6. retry, idempotency, or status behavior, if applicable;
|
||||
7. links to package-specific docs and canonical integration contracts.
|
||||
|
||||
Package-specific docs should be named `pkg-<name>.md` and should include:
|
||||
|
||||
1. import path;
|
||||
2. intended use cases;
|
||||
3. primary types and functions needed by consumers;
|
||||
4. minimal examples;
|
||||
5. validation, error, retry, and boundary behavior;
|
||||
6. links to canonical file-format or wire-protocol contracts.
|
||||
|
||||
### docs/internal/
|
||||
|
||||
**Audience:** developers, LLM coding agents
|
||||
|
||||
Required for modular, service-oriented, or orchestration projects.
|
||||
|
||||
This directory describes implemented internal components. It is not the roadmap.
|
||||
|
||||
Use one file per major component where useful.
|
||||
|
||||
Each component doc should include:
|
||||
|
||||
1. purpose;
|
||||
2. inputs and outputs;
|
||||
3. boundaries;
|
||||
4. config fields used;
|
||||
5. external adapters used;
|
||||
6. state or manifest behavior, if applicable;
|
||||
7. skip/resume behavior, if applicable;
|
||||
8. failure behavior;
|
||||
9. tests to inspect before changing;
|
||||
10. architectural invariants.
|
||||
|
||||
### docs/roadmap/
|
||||
|
||||
**Audience:** maintainers, developers, LLM coding agents
|
||||
|
||||
This is the only place for planned, future, aspirational, experimental, or unimplemented work.
|
||||
|
||||
Roadmap docs should clearly distinguish:
|
||||
|
||||
- proposed work;
|
||||
- accepted plans;
|
||||
- deferred ideas;
|
||||
- rejected ideas;
|
||||
- implementation prompts or task breakdowns, if useful.
|
||||
|
||||
Roadmap docs should not be confused with current behavior.
|
||||
|
||||
### docs/integrations/
|
||||
|
||||
**Audience:** developers, LLM coding agents
|
||||
|
||||
Required for projects that depend on external CLIs, APIs, services, protocols, or file formats where the integration contract is important to maintain.
|
||||
|
||||
This directory contains concise, versioned reference notes for external integration contracts. It should document only the parts of the external system that this project actually uses or exposes.
|
||||
|
||||
For public HTTP API services, `docs/integrations/` should document upstream, downstream, storage, protocol, or runtime contracts that the service depends on or bridges. It should not become a second copy of the public HTTP endpoint reference; that belongs in `docs/api.md`.
|
||||
|
||||
Use one file per integration where useful.
|
||||
|
||||
## Examples Directory
|
||||
|
||||
Projects with non-trivial configuration or workflows should include `examples/`.
|
||||
|
||||
Useful examples include:
|
||||
|
||||
- minimal working config;
|
||||
- production-oriented config;
|
||||
- full annotated config;
|
||||
- local development config;
|
||||
- remote/object-storage config;
|
||||
- minimal session/input file.
|
||||
|
||||
Examples should be valid, maintained, tested when practical, and linked from relevant docs.
|
||||
|
||||
## Security and Privacy
|
||||
|
||||
Docs and examples must not include:
|
||||
|
||||
- real API keys;
|
||||
- tokens;
|
||||
- passwords;
|
||||
- private keys;
|
||||
- private environment dumps;
|
||||
- sensitive user data;
|
||||
- raw private transcripts;
|
||||
- private infrastructure details unless intentionally public.
|
||||
|
||||
Document secret-handling mechanisms, not actual secret values.
|
||||
|
||||
## Maintenance Rules
|
||||
|
||||
When docs change, verify the affected behavior.
|
||||
|
||||
Where practical:
|
||||
|
||||
- load example config files in tests;
|
||||
- test CLI examples or command parser behavior;
|
||||
- validate documented flags against real flags;
|
||||
- remove stale references;
|
||||
- update links after renames;
|
||||
- keep roadmap content out of non-roadmap docs.
|
||||
|
||||
If documentation and code disagree, fix the documentation and/or open a roadmap item; do not leave aspirational behavior in current-behavior docs.
|
||||
|
||||
Documentation is complete only when it matches the current code.
|
||||
|
||||
## Documentation Change Checklist
|
||||
|
||||
Before merging documentation changes, verify:
|
||||
|
||||
- README is concise and orientation-focused.
|
||||
- `docs/policy/architecture.md` describes development principles.
|
||||
- `docs/api.md` is the canonical HTTP contract for HTTP API services.
|
||||
- Future work appears only under `docs/roadmap/`.
|
||||
- User-facing docs avoid unnecessary internals.
|
||||
- Consumer-facing docs explain public APIs without duplicating HTTP endpoint or integration contracts.
|
||||
- Developer-facing docs preserve boundaries and invariants.
|
||||
- Config examples match the schema.
|
||||
- CLI examples match real commands and flags.
|
||||
- Defaults appear in the canonical config reference.
|
||||
- No secrets or private data are included.
|
||||
- Links are accurate.
|
||||
Complete copyable files belong in `examples/`. Documentation may use the
|
||||
smallest illustrative snippet needed to explain its owned topic, but should link
|
||||
to maintained examples instead of embedding a second complete copy.
|
||||
|
||||
Examples must be valid, secret-free, and tested where practical. Commands and
|
||||
configuration used in documentation should match the application.
|
||||
|
||||
### Security And Privacy
|
||||
|
||||
Documentation and examples must not contain real credentials, private keys,
|
||||
private environment dumps, sensitive source material, or private infrastructure
|
||||
details unless intentionally public. Document secret-handling mechanisms, not
|
||||
secret values.
|
||||
|
||||
## Canonical Ownership
|
||||
|
||||
| Topic | Canonical owner | Owned content | Content owned elsewhere |
|
||||
| --- | --- | --- | --- |
|
||||
| Product orientation and minimal end-to-end quickstart | `README.md` | What Notarius is, why it is useful, one shortest successful invocation, and links onward. | Complete command reference, configuration reference, operational procedures, implementation detail. |
|
||||
| Contributor entry point | `docs/development.md` | Task-oriented reading guide, minimal contributor orientation, baseline validation commands, and links to canonical docs. | Package inventory, architecture rules, subsystem behavior, detailed change recipes. |
|
||||
| Current application architecture | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, architectural boundaries, invariants, safety properties, and non-goals. | Concrete package inventory, implementation mechanics, contributor procedures, decision history, future work. |
|
||||
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and ADR/document lifecycle. | Application architecture or product behavior. |
|
||||
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression-test policy, and criteria for adding, rewriting, or deleting tests. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
|
||||
| CLI contract | `docs/cli.md` | Commands, arguments, flags, invocation semantics, and exit codes. | End-to-end operating procedures, configuration field definitions, runtime filesystem layout, module implementation details. |
|
||||
| Configuration contract | `docs/config.md` | Discovery and precedence, file schema, fields, defaults, environment overrides, validation rules, and user-selectable module or validator keys. | Complete example files, CLI syntax, runtime state lifecycle, module implementation details. |
|
||||
| Operations | `docs/operations.md` | Runtime workflows, physical filesystem and state layout, output, cache, and debug handling, resume, cleanup, permissions, recovery, and operational limits. | CLI flag syntax, configuration field definitions, logical output schemas, implementation mechanics. |
|
||||
| Public HTTP contract, if introduced | `docs/api.md` | Routes, authentication, media types, request and response schemas, status codes, pagination, caching, idempotency, rate limits, and HTTP retry semantics. | Client walkthroughs, upstream or downstream integration internals, implementation detail. |
|
||||
| Consumer guidance, if a public package or API is introduced | `docs/consumers/` | Task-oriented use of the public interface, minimal client examples, and consumer responsibilities. | HTTP wire semantics, external protocol contracts, internal implementation detail. |
|
||||
| External and durable integration contracts | `docs/integrations/` | External file formats and protocols, upstream and downstream contracts, logical output bundle paths and schemas, media types, and compatibility behavior. | Physical runtime placement and lifecycle, internal transformations, CLI syntax, configuration defaults. |
|
||||
| Implemented component inventory | `docs/internal/overview.md` | Current packages and components, their implemented responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
|
||||
| Internal component behavior | Other files under `docs/internal/` | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
|
||||
| Architectural decision history | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
|
||||
| Future work and implementation status | `docs/roadmap/` | Proposed, accepted, deferred, or rejected work; implementation status; sequencing; and task breakdowns. | Implemented behavior reference and architectural decision rationale. |
|
||||
| Complete copyable artifacts | `examples/` | Maintained configuration, inputs, and other files intended to be copied or run. | Field-by-field reference, command reference, prose explanation. |
|
||||
|
||||
Documents that do not exist are required only when the corresponding interface
|
||||
or responsibility exists. Do not create placeholder API, consumer, integration,
|
||||
or operations documents for behavior the application does not have.
|
||||
|
||||
## Boundary Rules
|
||||
|
||||
### Orientation
|
||||
|
||||
The README owns product orientation. The developer guide routes contributors.
|
||||
Architecture owns normative structure. Internal overview owns the current
|
||||
concrete component map. These documents may link to one another but should not
|
||||
maintain parallel package or behavior descriptions.
|
||||
|
||||
### Commands, Configuration, And Operations
|
||||
|
||||
CLI documentation answers how to invoke the application. Configuration
|
||||
documentation answers what settings mean. Operations answers what happens to
|
||||
runtime state and how to operate or recover the application. When a workflow
|
||||
crosses these topics, choose the document that owns the task and link to the
|
||||
other contracts.
|
||||
|
||||
### Contracts And Implementation
|
||||
|
||||
Integration and API documents define externally observable shapes and
|
||||
semantics. Internal documents explain how Notarius implements or consumes those
|
||||
contracts. Internal docs may name a field, file, or protocol to identify a
|
||||
dependency, but must link to its canonical contract for the definition.
|
||||
|
||||
### Security Topics
|
||||
|
||||
This policy owns what documentation and examples may contain. Architecture owns
|
||||
application security invariants. Configuration owns credential-supply
|
||||
mechanisms. Operations owns permissions and handling of sensitive runtime
|
||||
artifacts. Internal docs own implementation mechanisms only.
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
Use sequentially numbered ADR filenames such as
|
||||
`0001-record-architecture-decisions.md`. Follow the lightweight Nygard format:
|
||||
|
||||
1. title;
|
||||
2. status;
|
||||
3. date;
|
||||
4. context;
|
||||
5. decision;
|
||||
6. alternatives considered;
|
||||
7. consequences.
|
||||
|
||||
Treat the decision content of an accepted ADR as immutable. When a decision
|
||||
changes, create a new ADR and update the earlier ADR's status to superseded.
|
||||
Rejected architectural alternatives belong in the ADR; rejected product ideas
|
||||
belong in the roadmap.
|
||||
|
||||
## Maintenance
|
||||
|
||||
When behavior changes, update its canonical owner in the same change. If
|
||||
ownership moves, remove the old definition and replace it with a link where
|
||||
navigation remains useful.
|
||||
|
||||
Before completing documentation work:
|
||||
|
||||
- verify affected behavior and examples;
|
||||
- check commands, flags, fields, defaults, schemas, and paths against their
|
||||
implementation;
|
||||
- keep unimplemented behavior in the roadmap, subject to the ADR exception;
|
||||
- remove stale references and validate links;
|
||||
- confirm that non-owning documents summarize and link rather than redefine;
|
||||
- confirm that no secrets or sensitive private data were added.
|
||||
|
||||
296
docs/policy/testing.md
Normal file
296
docs/policy/testing.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# Testing Policy
|
||||
|
||||
## Purpose
|
||||
|
||||
Our tests exist to make **incorrect changes expensive and correct changes cheap**.
|
||||
|
||||
We do not optimize for test count, line coverage, exhaustive isolation, or the fewest possible tests. We optimize for sufficient confidence in important behavior while imposing as little unnecessary friction as possible on future development.
|
||||
|
||||
## Every test has a cost
|
||||
|
||||
Testing is not an unqualified good. Every test imposes both an immediate cost and a continuing lifetime cost.
|
||||
|
||||
A test must be:
|
||||
|
||||
- written and reviewed;
|
||||
- understood by future maintainers and coding agents;
|
||||
- executed in local and CI workflows;
|
||||
- diagnosed when it fails;
|
||||
- updated when legitimate behavior changes;
|
||||
- maintained as fixtures, APIs, and dependencies evolve; and
|
||||
- removed or rewritten when it becomes redundant, brittle, misleading, or obsolete.
|
||||
|
||||
Tests also create cognitive and architectural friction. They can constrain refactoring, duplicate policy, slow feedback loops, add noise to failures, and cause harmless implementation changes to require unrelated edits across the suite.
|
||||
|
||||
A test is warranted only when the confidence it provides justifies these costs.
|
||||
|
||||
Apply this cost-benefit analysis at two levels:
|
||||
|
||||
1. **Per test:** What realistic defect does this test detect, how consequential would that defect be, and is that protection worth the test's lifetime cost?
|
||||
2. **Across the suite:** Does this collection provide materially more confidence than a smaller, simpler suite would?
|
||||
|
||||
The preferred test suite is a **lean suite that provides sufficient confidence in the risks that matter, without redundant or low-value tests**. We seek sufficient confidence with the least unnecessary testing friction, not the fewest possible tests.
|
||||
|
||||
Some friction is intentional. Tests should make dangerous changes—such as breaking compatibility, corrupting data, violating security boundaries, or reintroducing subtle bugs—require deliberate review. They should not make ordinary internal changes needlessly expensive.
|
||||
|
||||
The cost of a test is not a reason to omit testing by default. Do not cite maintenance cost abstractly. When omitting a plausible test, be able to state why the protected failure is low-risk, already covered, obvious, reversible, or cheaper to detect elsewhere. For consequential, subtle, or difficult-to-observe behavior, the presumption should favor testing.
|
||||
|
||||
## Default testing style
|
||||
|
||||
Use a **classical/Detroit-style** approach:
|
||||
|
||||
- Test observable behavior, resulting state, contracts, and invariants.
|
||||
- Use real internal collaborators when they are fast and deterministic.
|
||||
- Use fakes, stubs, or mocks primarily at expensive, nondeterministic, destructive, or external boundaries.
|
||||
- Prefer package-level behavioral tests over tests coupled to private helpers or internal call sequences.
|
||||
- Treat exact collaborator interactions as testable behavior only when the interaction itself is a requirement.
|
||||
|
||||
Examples of appropriate seams include clocks, randomness, subprocesses, remote APIs, object storage, email, and paid LLM calls.
|
||||
|
||||
## Test execution requirements
|
||||
|
||||
Tests in the default suite must be deterministic, offline, and independent of real credentials. They must not invoke paid APIs or depend on mutable external services. Tests that require live infrastructure must be explicitly opt-in and clearly separated from the default suite.
|
||||
|
||||
Control clocks, randomness, environment variables, and other process-global or machine-specific state when they affect behavior. Tests should be safe to run repeatedly and alongside other tests without depending on execution order or state left by an earlier test.
|
||||
|
||||
## What deserves tests
|
||||
|
||||
Prioritize tests for:
|
||||
|
||||
1. Public and package-level contracts.
|
||||
2. Domain rules and important invariants.
|
||||
3. Boundary conditions and malformed input.
|
||||
4. Failure handling, cancellation, retries, recovery, and partial success.
|
||||
5. Serialization, schemas, compatibility, and round trips.
|
||||
6. Previously observed or plausible regressions.
|
||||
7. Representative integration and end-to-end workflows.
|
||||
|
||||
A package-level contract is behavior relied upon by another package or major collaborator, not every observable detail of a package implementation.
|
||||
|
||||
For behavior involving **data integrity, destructive operations, compatibility, security, concurrency, idempotency, or recovery**, presume that durable tests are required unless the behavior is already credibly protected at another layer.
|
||||
|
||||
Do not add tests merely because a function, branch, or line exists. Do not add a test when the same meaningful risk is already adequately protected elsewhere.
|
||||
|
||||
## Choose the right test boundary
|
||||
|
||||
Test through the narrowest stable boundary that expresses the behavior clearly.
|
||||
|
||||
This is often the package API, but it may instead be:
|
||||
|
||||
- a smaller pure function when dense domain logic is most clearly isolated there;
|
||||
- a package-level operation when several internal collaborators jointly produce the behavior; or
|
||||
- a larger integration boundary when correctness emerges from interaction with a real dependency.
|
||||
|
||||
Do not force all behavior through oversized end-to-end tests. Do not test every private helper merely because it exists. Choose the boundary that gives durable confidence with the least incidental coupling.
|
||||
|
||||
## Test behavior, not implementation
|
||||
|
||||
A test should protect a decision, contract, or invariant—not memorialize the current implementation.
|
||||
|
||||
Before adding or retaining a test, ask:
|
||||
|
||||
> What realistic defect would this test catch?
|
||||
|
||||
A test is suspect when its main purpose is to detect that someone:
|
||||
|
||||
- changed an internal constant;
|
||||
- renamed or split a private helper;
|
||||
- reordered equivalent internal operations;
|
||||
- changed incidental formatting;
|
||||
- replaced one correct algorithm with another; or
|
||||
- refactored internal object structure without changing behavior.
|
||||
|
||||
Refactoring should normally require no test edits unless the refactored structure is itself part of the contract.
|
||||
|
||||
A test can be factually correct and still have negative value. Accurately describing current behavior is not enough; the protected behavior must be important enough to justify the future friction.
|
||||
|
||||
## Expected effects of different changes
|
||||
|
||||
Use the following expectations when evaluating test failures and test maintenance:
|
||||
|
||||
| Change | Expected effect on tests |
|
||||
|---|---|
|
||||
| Internal refactor that preserves behavior | Existing tests should normally remain unchanged and continue to pass. |
|
||||
| Change to an internal default with no contractual significance | Behavioral tests should normally remain unchanged; tests should derive expectations from configuration or relationships rather than duplicate the old value. |
|
||||
| Intentional change to public behavior, policy, schema, or compatibility guarantees | The relevant tests should be reviewed and changed deliberately. |
|
||||
| Accidental violation of a contract or invariant | Tests should fail; fix the production code rather than rewriting the tests to accept the defect. |
|
||||
|
||||
A test failing is not the same as a test needing to be edited. Many tests may correctly fail because of one production defect. The maintenance smell is a correct internal change that requires unrelated expectation updates throughout the suite.
|
||||
|
||||
## Separate mechanism from policy
|
||||
|
||||
Configurable thresholds and defaults must not be duplicated throughout the test suite.
|
||||
|
||||
For example, do not encode an internal concurrency limit indirectly:
|
||||
|
||||
```go
|
||||
// Production policy:
|
||||
const maxConcurrency = 4
|
||||
|
||||
// Brittle test:
|
||||
err := startProcesses(5)
|
||||
require.Error(t, err)
|
||||
```
|
||||
|
||||
Instead, test the mechanism relationally:
|
||||
|
||||
```go
|
||||
const limit = 2
|
||||
runner := NewRunner(limit)
|
||||
|
||||
require.NoError(t, runner.Start(limit))
|
||||
require.ErrorIs(t, runner.Start(limit+1), ErrTooMuchConcurrency)
|
||||
```
|
||||
|
||||
The test should prove:
|
||||
|
||||
- the configured limit is accepted; and
|
||||
- one beyond the configured limit is rejected.
|
||||
|
||||
The production default should be tested exactly only when its literal value is itself a public, operational, safety, protocol, or compatibility requirement.
|
||||
|
||||
Apply the same rule to limits, timeouts, capacities, retry counts, and ranges: test relationships and behavior, not duplicated literals.
|
||||
|
||||
For concurrency limits, test both kinds of behavior when relevant:
|
||||
|
||||
1. **Configuration enforcement:** invalid or excessive requested values are handled correctly.
|
||||
2. **Runtime enforcement:** observed peak concurrency never exceeds the configured limit.
|
||||
|
||||
Use a test-controlled limit and measure the behavior relative to that limit. Do not merely assert today's default value.
|
||||
|
||||
## Avoid semantic duplication across layers
|
||||
|
||||
Each behavior should have a clear test owner.
|
||||
|
||||
- Parser tests own parsing cases.
|
||||
- Validator tests own validation rules.
|
||||
- Domain tests own transformations and invariants.
|
||||
- Adapter tests own external integration behavior.
|
||||
- Orchestrator tests own coordination and failure propagation.
|
||||
- CLI tests own argument and configuration mapping.
|
||||
- End-to-end tests prove that representative assembled workflows work.
|
||||
|
||||
Higher-level tests should not repeat every lower-level case. A single intentional policy change should not require unrelated edits across many test files.
|
||||
|
||||
Tests that are individually reasonable may still be collectively redundant. Evaluate the marginal value of each additional test in light of the protection already provided by the rest of the suite.
|
||||
|
||||
## Use test doubles deliberately
|
||||
|
||||
Choose the least elaborate test double that provides the required control or observation.
|
||||
|
||||
As a default:
|
||||
|
||||
1. Prefer real collaborators when they are fast and deterministic.
|
||||
2. Use small in-memory fakes when realistic stateful behavior is helpful.
|
||||
3. Use stubs when a dependency only needs to provide controlled responses.
|
||||
4. Use mocks when the interaction itself is contractual.
|
||||
|
||||
Mocks are appropriate when the contract includes facts such as:
|
||||
|
||||
- a notification is sent exactly once;
|
||||
- a transaction is committed only after successful writes;
|
||||
- cancellation reaches a subprocess;
|
||||
- an expensive API is called no more than once; or
|
||||
- a security audit event is emitted.
|
||||
|
||||
Do not use mocks merely to isolate every object or reproduce the implementation's call graph.
|
||||
|
||||
## Go-specific guidance
|
||||
|
||||
Use:
|
||||
|
||||
- table-driven tests for meaningful behavioral categories and boundaries;
|
||||
- `t.TempDir()` for real filesystem behavior;
|
||||
- `httptest.Server` for realistic HTTP interactions;
|
||||
- fuzz tests for parsers, normalization, path handling, and broad input spaces;
|
||||
- golden files only when the complete output is intentionally stable;
|
||||
- integration tests where correctness depends on component interaction; and
|
||||
- a small number of representative end-to-end tests.
|
||||
|
||||
Avoid exact error-string assertions unless the wording is itself contractual. Prefer `errors.Is`, `errors.As`, typed errors, or structured error fields.
|
||||
|
||||
At CLI boundaries, prefer exit classifications, structured output, and the smallest stable semantic fragment needed to identify the error. Do not snapshot complete diagnostic wording unless it is contractual.
|
||||
|
||||
Golden-file updates must require an explicit local flag. CI must not update golden files automatically, and reviewers must inspect the semantic diff before accepting an update.
|
||||
|
||||
Keep tests readable and direct. Test helpers and fixture frameworks must earn their own maintenance cost; do not build elaborate test infrastructure for small or isolated needs.
|
||||
|
||||
## Coverage
|
||||
|
||||
Coverage is a diagnostic, not a target.
|
||||
|
||||
Use it to find untested critical branches and unexpectedly weak packages. Do not write low-value tests solely to increase a percentage, and do not infer test quality from coverage alone.
|
||||
|
||||
Pure domain logic will often warrant higher coverage than CLI wiring or external adapters. Uneven coverage is acceptable when it reflects risk.
|
||||
|
||||
Increasing coverage is valuable only when the newly covered behavior protects a meaningful risk at an acceptable cost.
|
||||
|
||||
## Regression tests
|
||||
|
||||
A bug fix should normally include a regression test that fails before the fix and passes afterward.
|
||||
|
||||
Retain the test when the defect could realistically recur and its consequences justify the ongoing cost. Prefer the narrowest durable test of the violated contract or invariant; do not preserve accidental implementation details from the original bug.
|
||||
|
||||
Not every historical bug requires a permanent test. If the underlying design has made recurrence impossible, the test has become redundant, or a stronger invariant test now subsumes it, remove or consolidate it.
|
||||
|
||||
## Deleting or rewriting tests
|
||||
|
||||
Tests are maintained code, not permanent historical artifacts.
|
||||
|
||||
Delete or rewrite a test when its maintenance cost exceeds the confidence it provides.
|
||||
|
||||
Strong candidates include tests that:
|
||||
|
||||
- require updates after harmless internal changes;
|
||||
- directly assert private constants without protecting a real contract;
|
||||
- duplicate the same policy across several layers;
|
||||
- verify mock choreography rather than outcomes;
|
||||
- snapshot large amounts of incidental output;
|
||||
- test trivial private helpers already exercised through stable package behavior;
|
||||
- protect risks already covered more effectively elsewhere;
|
||||
- are flaky, misleading, obsolete, or disproportionately expensive to diagnose; or
|
||||
- no longer correspond to a plausible failure mode.
|
||||
|
||||
Several brittle tests may encode one genuine requirement. Replace them with one durable behavior-level or invariant test rather than preserving all of them.
|
||||
|
||||
Deleting a low-value test can improve the quality of the suite by reducing noise, maintenance burden, and friction around legitimate change.
|
||||
|
||||
## Reviewing a proposed test
|
||||
|
||||
Use the following questions when the value, boundary, or durability of a proposed test is not self-evident. Significant test additions should be reviewable against them, but written answers are not required for every routine test.
|
||||
|
||||
1. What realistic defect would it catch?
|
||||
2. How likely is that defect?
|
||||
3. How consequential would it be?
|
||||
4. Is the behavior already protected elsewhere?
|
||||
5. At which layer should this behavior be owned?
|
||||
6. Does the test assert a durable contract or an incidental implementation detail?
|
||||
7. Could the implementation be refactored without changing the behavior and without editing this test?
|
||||
8. What should cause this test to fail?
|
||||
9. What legitimate changes should not cause this test to fail?
|
||||
10. What ongoing maintenance, execution, and diagnostic cost will the test impose?
|
||||
11. Is there a smaller or more direct test that protects the same risk?
|
||||
|
||||
Do not add the test when its expected lifetime cost exceeds its expected protective value.
|
||||
|
||||
When deciding not to test plausible behavior, record or be able to explain why the risk is low, already protected, obvious, reversible, or cheaper to detect elsewhere.
|
||||
|
||||
## Definition of sufficient
|
||||
|
||||
A test suite is sufficient when:
|
||||
|
||||
- important contracts and invariants are protected;
|
||||
- meaningful boundaries and failure modes are exercised;
|
||||
- realistic and consequential regressions are credibly protected against silent recurrence;
|
||||
- behavior involving data integrity, destructive operations, compatibility, security, concurrency, idempotency, and recovery is credibly protected;
|
||||
- important external boundaries have realistic integration coverage;
|
||||
- representative complete workflows are tested;
|
||||
- failures provide useful signal rather than redundant noise;
|
||||
- legitimate internal changes usually do not require test edits; and
|
||||
- additional tests would mostly repeat existing protection or preserve inconsequential implementation details.
|
||||
|
||||
Sufficiency is a risk judgment, not a coverage percentage or test count. Reassess it as the application, its users, and the consequences of failure evolve.
|
||||
|
||||
The governing rule is:
|
||||
|
||||
> Test heavily where failure is consequential, subtle, or difficult to detect after the fact. Test lightly where failure is obvious, reversible, and inexpensive—and retain no test whose lifetime cost exceeds the confidence it provides.
|
||||
134
docs/roadmap/future.md
Normal file
134
docs/roadmap/future.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Future Work
|
||||
|
||||
Current Notarius behavior is documented in the canonical README, CLI,
|
||||
configuration, operations, internal, and integration docs. This roadmap records
|
||||
future work only. Items are ordered roughly by current value and specificity,
|
||||
not as committed release dates.
|
||||
|
||||
## Near-Term D&D Pipeline
|
||||
|
||||
### Evaluate Spell Extraction And Normalization
|
||||
|
||||
- Evaluate ordinary extraction retries and the completed normalization path
|
||||
against a human-reviewed transcript set before adding repair-aware retries or
|
||||
an LLM-backed semantic validator.
|
||||
- Maintain a small set of human-reviewed transcripts and outputs for prompt,
|
||||
validator, and normalizer development. Treat model-quality review as an
|
||||
iterative human evaluation aid, not a deterministic correctness gate.
|
||||
|
||||
### Use D&D Scene Chunking
|
||||
|
||||
- Allow the combat extractor to no-op for chunks classified as non-combat only
|
||||
after the scene-description artifact can be supplied through an explicit
|
||||
ordered dependency. Do not make generic chunk materialization depend on a D&D
|
||||
classification.
|
||||
- Use ordered pipeline steps whenever a later artifact needs an accepted
|
||||
earlier artifact as context. Keep independent lanes in the same step and do
|
||||
not introduce a general DAG or concurrent cross-lane reconciliation model.
|
||||
- Reassess whether one shared scene plan provides enough context for NPC,
|
||||
spell, combat, interaction, and scene-description lanes after real-world use.
|
||||
Add more complex chunking only in response to demonstrated failures.
|
||||
|
||||
## Shared Normalization And Quality Work
|
||||
|
||||
### Generic LLM-Assisted Deduplication
|
||||
|
||||
- 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 have stable unique IDs. Artifact-kind registrations or
|
||||
adapters may expose that structure without moving domain rules into the
|
||||
generic package.
|
||||
- Keep mutation deterministic: parse and validate the model's duplicate groups,
|
||||
require every referenced ID to exist, reject overlapping or malformed groups,
|
||||
prevent unrelated insertion or deletion, and apply only approved replacement
|
||||
operations in code.
|
||||
- Preserve provenance needed for audit and downstream validation, and emit
|
||||
warnings describing every collapsed group.
|
||||
- Evaluate batching and context-window limits before applying the normalizer to
|
||||
large artifact collections.
|
||||
|
||||
The model may use its own domain knowledge to judge semantic duplication; the
|
||||
generic implementation is responsible only for the common proposal contract,
|
||||
safety checks, and deterministic application of accepted changes.
|
||||
|
||||
### Validation And Review
|
||||
|
||||
- Add domain validators and production default chains alongside each new D&D
|
||||
artifact.
|
||||
- Add production LLM-backed validators only when a concrete review policy
|
||||
benefits from model judgment and deterministic checks are insufficient.
|
||||
- Add validator diagnostics and timing summaries if operators need more detail
|
||||
than the current [durable output bundle](../integrations/json-output.md)
|
||||
provides.
|
||||
- Add validator compatibility metadata if deployments need config-time proof
|
||||
that a validator is suitable for a particular stage, module, or artifact
|
||||
kind.
|
||||
- Add media-type validators when non-JSON artifact representations are
|
||||
introduced.
|
||||
|
||||
## Further Reference Evolution
|
||||
|
||||
- Make prior-run artifacts easier to bind as references without changing the
|
||||
existing module-facing reference-item contract.
|
||||
- Add structured or parsed references, such as typed NPC registries, rosters,
|
||||
or spell catalogs, when opaque UTF-8 prompt material is no longer sufficient.
|
||||
- Add per-slot or per-chunk inclusion policies so large references are not
|
||||
repeated in every prompt unnecessarily.
|
||||
- Add token budgeting and model context-window management for reference
|
||||
content.
|
||||
- Add reference caching, preprocessing, summarization, embedding, or retrieval
|
||||
only when reference size and observed model behavior justify them.
|
||||
- Extend generated references to prior-run artifacts or derived summaries only
|
||||
after same-run ordered handoffs establish the required provenance and
|
||||
lifecycle semantics.
|
||||
|
||||
## Design Considerations To Revisit
|
||||
|
||||
These concerns are relevant to ordered artifact dependencies but are not
|
||||
committed near-term features.
|
||||
|
||||
### Cross-artifact identity links
|
||||
|
||||
Evaluate whether downstream D&D artifacts should retain canonical NPC IDs from
|
||||
the generated NPC reference in addition to normalized display names. Any such
|
||||
contract must define player-character, unknown-actor, missing-NPC, and
|
||||
superseded-identity behavior before implementation. Deterministic validation
|
||||
may confirm that a linked ID exists in the consumed NPC artifact, but the link
|
||||
must never substitute for transcript evidence that the downstream event
|
||||
occurred.
|
||||
|
||||
### Artifact contract evolution
|
||||
|
||||
Define compatibility and migration policy before generated-reference chains
|
||||
must span multiple schema versions or long-lived historical artifacts. The
|
||||
policy should address stable identifier semantics, which schema changes permit
|
||||
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.
|
||||
|
||||
## Blue-Sky Platform And Operations
|
||||
|
||||
These ideas are intentionally less specified. Promote one into an earlier
|
||||
section only after a concrete workflow, contract, and priority emerge.
|
||||
|
||||
### Platform Extensions
|
||||
|
||||
- Additional input adapters, such as Markdown or note-export formats.
|
||||
- Additional output encoders.
|
||||
- Concurrent cross-lane entity normalization or broader workflow composition.
|
||||
- Batching or specialized context-window controls for LLM-backed validators.
|
||||
|
||||
### Distribution And Operations
|
||||
|
||||
- Packaged release artifacts for alpha distribution.
|
||||
- A documented versioning and release process.
|
||||
- Optional generated example-output fixtures with a regeneration procedure.
|
||||
- Additional diagnostics or reporting views.
|
||||
|
||||
### Workspace And Storage
|
||||
|
||||
- Default-idempotent run behavior with an explicit force override.
|
||||
- Remote workspace storage.
|
||||
- Workspace garbage collection and archival policies.
|
||||
- Cross-machine checkpoint reuse.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Future Work
|
||||
|
||||
Current Notarius behavior is documented in the canonical README, CLI,
|
||||
configuration, operations, internal, and integration docs. This roadmap records
|
||||
future work only.
|
||||
|
||||
## Candidate Product Work
|
||||
|
||||
- Additional input adapters, such as Markdown or note-export formats.
|
||||
- Additional D&D extractors beyond spell casts.
|
||||
- Cross-lane entity normalization.
|
||||
- Cross-chunk semantic deduplication.
|
||||
- Configurable validator chains with production validator modules.
|
||||
- Multiple effective LLM profiles in one run.
|
||||
- Parallel execution where it preserves deterministic manifests and diagnostics.
|
||||
- Additional output encoders.
|
||||
|
||||
## Candidate Operational Work
|
||||
|
||||
- Packaged release artifacts for alpha distribution.
|
||||
- A documented versioning and release process.
|
||||
- Optional generated example output fixtures with a regeneration procedure.
|
||||
- Additional diagnostics or reporting views if operator workflows need them.
|
||||
|
||||
## Non-Goals To Revisit Deliberately
|
||||
|
||||
- A general workflow language.
|
||||
- Structural module selection through ad hoc run flags.
|
||||
- Storing secrets in config files, diagnostics, manifests, or examples.
|
||||
@@ -1,215 +0,0 @@
|
||||
# Troubleshooting
|
||||
|
||||
This guide maps common implemented failure modes to inspection steps and fixes.
|
||||
For command syntax, see [CLI Reference](cli.md). For YAML fields and
|
||||
environment overrides, see [Configuration](config.md). For output and
|
||||
diagnostics layout, see [Operations](operations.md).
|
||||
|
||||
## Config File Not Found
|
||||
|
||||
Symptom:
|
||||
|
||||
```text
|
||||
notarius: config file not found; pass --config or set NOTARIUS_CONFIG
|
||||
```
|
||||
|
||||
Fix:
|
||||
|
||||
- Pass `--config path/to/config.yml`.
|
||||
- Or set `NOTARIUS_CONFIG` to a readable file.
|
||||
- Or install a config at `/usr/local/etc/notarius/config.yml`.
|
||||
|
||||
If the message says the config path is a directory or is not available, correct
|
||||
the path or file permissions.
|
||||
|
||||
## Unsupported Or Invalid Config
|
||||
|
||||
Symptoms include:
|
||||
|
||||
- `unsupported config version`
|
||||
- `config version is required`
|
||||
- `field <name> not found`
|
||||
- `total LLM concurrency must be greater than zero`
|
||||
- `diagnostics retention "<value>" is not supported`
|
||||
|
||||
Fix:
|
||||
|
||||
- Use `version: 1`.
|
||||
- Remove unknown YAML fields.
|
||||
- Validate with:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius config validate --config path/to/config.yml
|
||||
```
|
||||
|
||||
## Unknown Pipeline
|
||||
|
||||
Symptom:
|
||||
|
||||
```text
|
||||
notarius: pipeline "..." is not configured
|
||||
```
|
||||
|
||||
Fix:
|
||||
|
||||
- List configured pipeline IDs:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius pipelines list --config path/to/config.yml
|
||||
```
|
||||
|
||||
- Use one of those IDs in `notarius run <pipeline-id>`.
|
||||
- Check indentation under the top-level `pipelines` map.
|
||||
|
||||
## Unknown Or Incompatible Module
|
||||
|
||||
Symptoms mention a module key, pipeline slot, lane, capability, or `not
|
||||
registered`.
|
||||
|
||||
Fix:
|
||||
|
||||
- Validate the pipeline against the production module catalog:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius config validate \
|
||||
--config path/to/config.yml \
|
||||
--pipeline dnd-session
|
||||
```
|
||||
|
||||
- Use only implemented production module keys listed in
|
||||
[Configuration](config.md#implemented-production-modules).
|
||||
- Check that artifact lanes include an `extract` binding.
|
||||
|
||||
## Invalid `--only`
|
||||
|
||||
Symptoms include:
|
||||
|
||||
- `--only must contain comma-separated non-empty artifact lane IDs`
|
||||
- `--only requires --pipeline`
|
||||
- `selected artifact lane`
|
||||
|
||||
Fix:
|
||||
|
||||
- Use comma-separated lane IDs with no empty entries:
|
||||
|
||||
```sh
|
||||
go run ./cmd/notarius run dnd-session \
|
||||
--config path/to/config.yml \
|
||||
--input path/to/input.json \
|
||||
--only spells
|
||||
```
|
||||
|
||||
- For `config validate`, include `--pipeline` when using `--only`.
|
||||
- Confirm the lane ID exists under `pipelines.<id>.artifacts`.
|
||||
|
||||
## Seriatim Input Validation Failure
|
||||
|
||||
Symptoms include `seriatim input`, `parse JSON`, `segments must not be empty`,
|
||||
or validation errors naming a segment field.
|
||||
|
||||
Fix:
|
||||
|
||||
- Compare the input to
|
||||
[examples/seriatim-minimal-transcript.json](../examples/seriatim-minimal-transcript.json).
|
||||
- Ensure the JSON has a `metadata` object and a non-empty `segments` array.
|
||||
- Each segment needs a non-empty `id`, non-empty `speaker`, non-empty `text`,
|
||||
non-negative numeric `start`, and non-negative numeric `end`.
|
||||
- Segment IDs must be unique and must not contain leading or trailing
|
||||
whitespace.
|
||||
- `end` must be greater than or equal to `start`.
|
||||
|
||||
## Missing LLM Base URL Or Model
|
||||
|
||||
Symptoms include:
|
||||
|
||||
- `LLM profile "default" base URL must not be empty`
|
||||
- `LLM profile "default" model must not be empty`
|
||||
- `base URL must be valid`
|
||||
|
||||
Fix:
|
||||
|
||||
- Set `base_url` and `model` in `llm_profiles.default`.
|
||||
- Or set `NOTARIUS_LLM_DEFAULT_BASE_URL` and
|
||||
`NOTARIUS_LLM_DEFAULT_MODEL`.
|
||||
- If a profile needs authentication, set `api_key_env` in YAML or set
|
||||
`NOTARIUS_LLM_DEFAULT_API_KEY`.
|
||||
|
||||
## LLM Profile Override Failure
|
||||
|
||||
Symptom:
|
||||
|
||||
```text
|
||||
notarius: LLM profile override "..." is not configured
|
||||
```
|
||||
|
||||
Fix:
|
||||
|
||||
- Add the profile under `llm_profiles`.
|
||||
- Or use an existing profile ID with `--llm-profile`.
|
||||
|
||||
Current runs require exactly one distinct effective LLM profile. If a pipeline
|
||||
uses several profiles, run with `--llm-profile <id>` or align the bindings in
|
||||
configuration.
|
||||
|
||||
## Provider HTTP Or Response Failure
|
||||
|
||||
Symptoms include:
|
||||
|
||||
- `provider request failed`
|
||||
- `provider returned status 400`
|
||||
- `provider returned status 403`
|
||||
- `provider response missing choices`
|
||||
- `provider response assistant message content is not valid JSON`
|
||||
- `decode structured output`
|
||||
|
||||
Fix:
|
||||
|
||||
- Confirm the `base_url` points to an OpenAI-compatible endpoint root. Notarius
|
||||
posts to `<base_url>/chat/completions`.
|
||||
- Check `model` and provider credentials.
|
||||
- Inspect the retained diagnostics `error.log`.
|
||||
- For 400 and 403 responses, fix the request configuration or credentials.
|
||||
- For 429 and 5xx responses, the client retries according to `max_retries`; if
|
||||
the failure persists, inspect the provider response and adjust capacity,
|
||||
credentials, or model settings.
|
||||
- The assistant message content must decode as JSON matching the extractor's
|
||||
structured response schema.
|
||||
|
||||
Provider error messages are redacted for configured API key values.
|
||||
|
||||
## Output Write Failure
|
||||
|
||||
Symptoms include:
|
||||
|
||||
- `create output directory`
|
||||
- `write output file`
|
||||
- `output file name must`
|
||||
|
||||
Fix:
|
||||
|
||||
- Ensure `--output-dir` points to a directory path or a path that can be
|
||||
created.
|
||||
- Check filesystem permissions and available disk space.
|
||||
- If diagnostics were retained, inspect `run-report.json`, `run-manifest.json`,
|
||||
and `error.log`.
|
||||
|
||||
The CLI rejects unsafe logical output paths before writing files.
|
||||
|
||||
## Diagnostics Directory Surprise
|
||||
|
||||
Symptom: the diagnostics directory is missing after a successful run.
|
||||
|
||||
Fix:
|
||||
|
||||
- Check `diagnostics.retention`.
|
||||
- With `auto`, successful runs without warnings are removed.
|
||||
- Use `diagnostics.retention: always` when every diagnostics run directory
|
||||
should be kept.
|
||||
- Use `--diagnostics-dir` to override the configured work directory for a run.
|
||||
|
||||
Symptom: diagnostics exist even with `retention: never`.
|
||||
|
||||
Explanation:
|
||||
|
||||
- Failed runs are retained so that `error.log` and available context can be
|
||||
inspected.
|
||||
79
examples/dnd-complete.config.yml
Normal file
79
examples/dnd-complete.config.yml
Normal file
@@ -0,0 +1,79 @@
|
||||
version: 3
|
||||
concurrency:
|
||||
total_llm: 2
|
||||
stage_workers:
|
||||
extract: 2
|
||||
output:
|
||||
directory: ./notarius-output
|
||||
cache:
|
||||
chunk_plans:
|
||||
mode: auto
|
||||
directory: ./notarius-cache/chunk-plans
|
||||
checkpoints:
|
||||
enabled: true
|
||||
directory: ./notarius-cache/checkpoints
|
||||
debug:
|
||||
directory: ./notarius-debug
|
||||
pipelines:
|
||||
dnd-session:
|
||||
input: seriatim
|
||||
# Stable campaign context is shared by every module that accepts these slots.
|
||||
references:
|
||||
party: ./dnd-party.txt
|
||||
glossary: ./dnd-glossary.txt
|
||||
chunk:
|
||||
module: dnd/scenes
|
||||
retries: 2
|
||||
output:
|
||||
module: json
|
||||
options:
|
||||
include_chunk_map: true
|
||||
steps:
|
||||
# Establish session-wide reference artifacts before extracting events.
|
||||
- id: describe-session
|
||||
artifacts:
|
||||
npcs:
|
||||
extract:
|
||||
module: dnd/npcs
|
||||
retries: 2
|
||||
merge: appendorder
|
||||
normalize: dnd/npcs
|
||||
scene-descriptions:
|
||||
extract:
|
||||
module: dnd/scene-descriptions
|
||||
retries: 2
|
||||
merge: appendorder
|
||||
normalize: dnd/scene-descriptions
|
||||
- id: extract-events
|
||||
# The accepted NPC artifact is supplied in memory to every compatible
|
||||
# extractor and normalizer in this step.
|
||||
references:
|
||||
npcs:
|
||||
artifact:
|
||||
step: describe-session
|
||||
lane: npcs
|
||||
artifacts:
|
||||
spells:
|
||||
extract:
|
||||
module: dnd/spells
|
||||
retries: 2
|
||||
references:
|
||||
spell_catalog: ./dnd-spell-catalog.json
|
||||
merge: appendorder
|
||||
# Stage-local file references are intentionally bound at each stage.
|
||||
normalize:
|
||||
module: dnd/spells
|
||||
references:
|
||||
spell_catalog: ./dnd-spell-catalog.json
|
||||
combat-turns:
|
||||
extract:
|
||||
module: dnd/combat-turns
|
||||
retries: 2
|
||||
merge: appendorder
|
||||
normalize: dnd/combat-turns
|
||||
npc-interactions:
|
||||
extract:
|
||||
module: dnd/npc-interactions
|
||||
retries: 2
|
||||
merge: appendorder
|
||||
normalize: dnd/npc-interactions
|
||||
2
examples/dnd-glossary.txt
Normal file
2
examples/dnd-glossary.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Cure Wounds: healing spell cast by touch.
|
||||
Shield: defensive reaction spell.
|
||||
8
examples/dnd-minimal.config.yml
Normal file
8
examples/dnd-minimal.config.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
version: 3
|
||||
pipelines:
|
||||
dnd-session:
|
||||
input: seriatim
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
normalize: dnd/spells
|
||||
3
examples/dnd-party.txt
Normal file
3
examples/dnd-party.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
Aria: party cleric and recurring healer.
|
||||
Borin: fighter ally.
|
||||
Bandit mage: hostile spellcaster.
|
||||
21
examples/dnd-spell-catalog.json
Normal file
21
examples/dnd-spell-catalog.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schema_version": "notarius.dnd.spell-catalog-overlay.v1",
|
||||
"catalogs": [
|
||||
{
|
||||
"id": "notarius.example-campaign",
|
||||
"ruleset": "dnd-5e-2014",
|
||||
"source": {
|
||||
"title": "Notarius example campaign spell names",
|
||||
"version": "1",
|
||||
"url": "",
|
||||
"license": ""
|
||||
},
|
||||
"spells": [
|
||||
{
|
||||
"name": "Aegis of Emberfall",
|
||||
"aliases": ["Emberfall Aegis"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
version: 1
|
||||
llm_profiles:
|
||||
default:
|
||||
provider: openai-compatible
|
||||
base_url: http://127.0.0.1:1
|
||||
model: fake-model
|
||||
pipelines:
|
||||
dnd-session:
|
||||
input: seriatim
|
||||
chunk:
|
||||
module: generic
|
||||
options:
|
||||
max_units: 50
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
@@ -5,14 +5,14 @@
|
||||
},
|
||||
"segments": [
|
||||
{
|
||||
"id": "seg-001",
|
||||
"id": 1,
|
||||
"start": 0,
|
||||
"end": 4,
|
||||
"speaker": "Aria",
|
||||
"text": "Aria raises her holy symbol and casts Cure Wounds."
|
||||
},
|
||||
{
|
||||
"id": "seg-002",
|
||||
"id": 2,
|
||||
"start": 4,
|
||||
"end": 8,
|
||||
"speaker": "DM",
|
||||
|
||||
10
go.mod
10
go.mod
@@ -1,5 +1,11 @@
|
||||
module gitea.maximumdirect.net/eric/notarius
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.5
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/scriptorium v0.11.1
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require golang.org/x/text v0.40.0
|
||||
|
||||
12
go.sum
12
go.sum
@@ -1,3 +1,15 @@
|
||||
gitea.maximumdirect.net/eric/scriptorium v0.11.0 h1:rjvbt9FTaWHxYlHq7QlUzmMVUt3QdbTmeCkmH81N//o=
|
||||
gitea.maximumdirect.net/eric/scriptorium v0.11.0/go.mod h1:FQ5lEuNxmrQyNgIomkpZdxvfTC0jWjbXYuq3tbJWF64=
|
||||
gitea.maximumdirect.net/eric/scriptorium v0.11.1 h1:zBKtB3+fP8FcHGI8DJD99CiTL6crAGitBhWtE+xYJHc=
|
||||
gitea.maximumdirect.net/eric/scriptorium v0.11.1/go.mod h1:FQ5lEuNxmrQyNgIomkpZdxvfTC0jWjbXYuq3tbJWF64=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
314
internal/cli/assembled_spell_pipeline_contract_test.go
Normal file
314
internal/cli/assembled_spell_pipeline_contract_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"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"
|
||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
|
||||
)
|
||||
|
||||
const assembledSpellExtractorKey = "test/dnd/spell-casts"
|
||||
|
||||
func TestAssembledSpellPipelineNormalizesMergedCasts(t *testing.T) {
|
||||
registries, resolved, extractor := assembledSpellPipeline(t, assembledSpellPipelineOptions{})
|
||||
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
|
||||
Prepared: prepared,
|
||||
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
|
||||
ChunkCacheMode: pipeline.ChunkCacheBypass,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
chunkIndexes := extractor.chunkIndexesSnapshot()
|
||||
sort.Ints(chunkIndexes)
|
||||
if !reflect.DeepEqual(chunkIndexes, []int{0, 1}) {
|
||||
t.Fatalf("extractor chunk indexes = %#v, want two chunk-boundary calls", chunkIndexes)
|
||||
}
|
||||
if output.Manifest.ValidationStatus != "approved" || len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 {
|
||||
t.Fatalf("run output = %#v, want approved normalized output without rejections", output)
|
||||
}
|
||||
if output.NormalizeOutputs[0].NormalizerKey != spellnormalize.Key {
|
||||
t.Fatalf("normalized output module = %q, want %q", output.NormalizeOutputs[0].NormalizerKey, spellnormalize.Key)
|
||||
}
|
||||
|
||||
var normalized dnd.SpellList
|
||||
if err := json.Unmarshal(output.NormalizeOutputs[0].Artifact.Content, &normalized); err != nil {
|
||||
t.Fatalf("decode normalized output: %v", err)
|
||||
}
|
||||
if len(normalized.SpellCasts) != 2 {
|
||||
t.Fatalf("normalized casts = %#v, want collapsed duplicate plus distinct evidence", normalized.SpellCasts)
|
||||
}
|
||||
first, distinct := normalized.SpellCasts[0], normalized.SpellCasts[1]
|
||||
if first.Spell != "Cure Wounds" || first.Caster != " Aria \t" {
|
||||
t.Fatalf("retained cast = %#v, want canonical spell with first occurrence caster", first)
|
||||
}
|
||||
if !reflect.DeepEqual(first.SourceRefs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) {
|
||||
t.Fatalf("retained refs = %#v, want sorted complete evidence", first.SourceRefs)
|
||||
}
|
||||
if distinct.Spell != "Cure Wounds" || distinct.Caster != "aria" || !reflect.DeepEqual(distinct.SourceRefs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) {
|
||||
t.Fatalf("distinct cast = %#v, want separate evidence event", distinct)
|
||||
}
|
||||
|
||||
wantWarningReasons := []string{
|
||||
spellnormalize.ReasonCodeSpellNameCanonicalized,
|
||||
spellnormalize.ReasonCodeSourceReferencesNormalized,
|
||||
spellnormalize.ReasonCodeDuplicateSpellCastCollapsed,
|
||||
"spell_not_near_source",
|
||||
}
|
||||
gotWarningReasons := make([]string, len(output.Warnings))
|
||||
for index, warning := range output.Warnings {
|
||||
gotWarningReasons[index] = warning.ReasonCode
|
||||
}
|
||||
if !reflect.DeepEqual(gotWarningReasons, wantWarningReasons) {
|
||||
t.Fatalf("warnings = %#v, want deterministic normalize and validation warnings", output.Warnings)
|
||||
}
|
||||
if output.Warnings[2].Scope != "spell_casts[0]" || !strings.Contains(output.Warnings[2].Message, "retained input index 0") || !strings.Contains(output.Warnings[2].Message, "removed input indices [1]") {
|
||||
t.Fatalf("duplicate warning = %#v, want retained and removed merged indices", output.Warnings[2])
|
||||
}
|
||||
|
||||
warningsFile := decodeAssembledOutput[struct {
|
||||
Warnings []contracts.Warning `json:"warnings"`
|
||||
}](t, output.OutputFiles, "warnings.json")
|
||||
if !reflect.DeepEqual(warningsFile.Warnings, output.Warnings) {
|
||||
t.Fatalf("warnings file = %#v, run warnings = %#v, want manifest output path to preserve warnings", warningsFile.Warnings, output.Warnings)
|
||||
}
|
||||
manifest := decodeAssembledOutput[artifacts.RunManifest](t, output.OutputFiles, "manifest.json")
|
||||
if len(manifest.ArtifactLanes) != 1 || manifest.ArtifactLanes[0].Normalizer != spellnormalize.Key {
|
||||
t.Fatalf("manifest lanes = %#v, want assembled spell normalizer", manifest.ArtifactLanes)
|
||||
}
|
||||
normalizerMetadata, ok := manifest.ArtifactLanes[0].Metadata["normalizer"].(map[string]any)
|
||||
_, hasOverlayIDs := normalizerMetadata["catalog_overlay_ids"]
|
||||
if !ok || normalizerMetadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || !strings.HasPrefix(stringValue(normalizerMetadata["catalog_digest"]), "sha256:") || !hasOverlayIDs {
|
||||
t.Fatalf("normalizer manifest metadata = %#v, want base ID, digest, and overlay IDs", manifest.ArtifactLanes[0].Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembledSpellPipelineHonorsNormalizeValidatorOverride(t *testing.T) {
|
||||
registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{normalizeValidatorOverride: true})
|
||||
var normalizeChain *pipeline.ResolvedValidatorChain
|
||||
for index := range resolved.ValidatorChains {
|
||||
chain := &resolved.ValidatorChains[index]
|
||||
if chain.Stage == pipeline.StageNormalize && chain.ModuleKey == spellnormalize.Key && chain.LaneID == "spells" {
|
||||
normalizeChain = chain
|
||||
break
|
||||
}
|
||||
}
|
||||
if normalizeChain == nil || len(normalizeChain.Validators) != 1 || normalizeChain.Validators[0].Binding.Module != "generic/always_accept" {
|
||||
t.Fatalf("normalize validator chain = %#v, want explicit always-accept override", normalizeChain)
|
||||
}
|
||||
|
||||
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare() error = %v, want nil", err)
|
||||
}
|
||||
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
|
||||
Prepared: prepared,
|
||||
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
|
||||
ChunkCacheMode: pipeline.ChunkCacheBypass,
|
||||
})
|
||||
if err != nil || output.Manifest.ValidationStatus != "approved" || len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 {
|
||||
t.Fatalf("Run() error = %v output = %#v, want approved override run", err, output)
|
||||
}
|
||||
for _, warning := range output.Warnings {
|
||||
if warning.ReasonCode == "spell_not_near_source" {
|
||||
t.Fatalf("warnings = %#v, want explicit validator override to replace default relatedness chain", output.Warnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembledSpellPipelineRejectsUnknownSpellWithoutPromotingAttemptWarning(t *testing.T) {
|
||||
registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{unknownSpell: true})
|
||||
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare() error = %v, want nil", err)
|
||||
}
|
||||
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
|
||||
Prepared: prepared,
|
||||
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
|
||||
ChunkCacheMode: pipeline.ChunkCacheBypass,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v, want nil", err)
|
||||
}
|
||||
if output.Manifest.ValidationStatus != "rejected" || len(output.NormalizeOutputs) != 0 || len(output.Rejected) != 1 {
|
||||
t.Fatalf("run output = %#v, want one rejected normalize candidate and no normalized output", output)
|
||||
}
|
||||
rejection := output.Rejected[0]
|
||||
if rejection.Stage != string(pipeline.StageNormalize) || rejection.LaneID != "spells" || rejection.ModuleKey != spellnormalize.Key || rejection.ValidatorName != "extract/dnd/spells/catalog" || rejection.ReasonCode != "unknown_spell" {
|
||||
t.Fatalf("rejection = %#v, want durable normalize catalog rejection", rejection)
|
||||
}
|
||||
rejectedFile := decodeAssembledOutput[struct {
|
||||
Rejected []contracts.RejectedOutput `json:"rejected"`
|
||||
}](t, output.OutputFiles, "rejected.json")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembledSpellPipelinePromotesUnknownSpellWarningWhenOverrideAccepts(t *testing.T) {
|
||||
registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{normalizeValidatorOverride: true, unknownSpell: true})
|
||||
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare() error = %v, want nil", err)
|
||||
}
|
||||
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
|
||||
Prepared: prepared,
|
||||
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
|
||||
ChunkCacheMode: pipeline.ChunkCacheBypass,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v, want nil", err)
|
||||
}
|
||||
if output.Manifest.ValidationStatus != "approved" || len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 {
|
||||
t.Fatalf("run output = %#v, want accepted unknown spell with explicit validator override", output)
|
||||
}
|
||||
var normalized dnd.SpellList
|
||||
if err := json.Unmarshal(output.NormalizeOutputs[0].Artifact.Content, &normalized); err != nil {
|
||||
t.Fatalf("decode normalized output: %v", err)
|
||||
}
|
||||
if len(normalized.SpellCasts) != 1 || normalized.SpellCasts[0].Spell != "Mysterious Burst" {
|
||||
t.Fatalf("normalized casts = %#v, want unresolved name preserved", normalized.SpellCasts)
|
||||
}
|
||||
if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != spellnormalize.ReasonCodeSpellNameUnresolved || output.Warnings[0].Scope != "spell_casts[0]" {
|
||||
t.Fatalf("warnings = %#v, want promoted scoped unresolved-name warning", output.Warnings)
|
||||
}
|
||||
warningsFile := decodeAssembledOutput[struct {
|
||||
Warnings []contracts.Warning `json:"warnings"`
|
||||
}](t, output.OutputFiles, "warnings.json")
|
||||
if !reflect.DeepEqual(warningsFile.Warnings, output.Warnings) {
|
||||
t.Fatalf("warnings file = %#v, run warnings = %#v, want durable unresolved-name warning", warningsFile.Warnings, output.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
type assembledSpellPipelineOptions struct {
|
||||
normalizeValidatorOverride bool
|
||||
unknownSpell bool
|
||||
}
|
||||
|
||||
func assembledSpellPipeline(t *testing.T, options assembledSpellPipelineOptions) (pipeline.Registries, pipeline.ResolvedPipeline, *assembledSpellExtractor) {
|
||||
t.Helper()
|
||||
components := productionTestComponents(t)
|
||||
extractor := &assembledSpellExtractor{unknownSpell: options.unknownSpell}
|
||||
if err := pipeline.RegisterExtractor[dnd.SpellList](components.registries.Extractors, pipeline.ModuleSpec{
|
||||
Key: assembledSpellExtractorKey,
|
||||
Stage: pipeline.StageExtract,
|
||||
Requires: []string{"chunks", "source.transcript"},
|
||||
Provides: []string{"dnd.spell_casts"},
|
||||
ArtifactKind: dnd.SpellListKind,
|
||||
}, func() (contracts.Extractor[dnd.SpellList], error) {
|
||||
return extractor, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("register assembled extractor: %v", err)
|
||||
}
|
||||
|
||||
normalize := pipeline.Binding(spellnormalize.Key)
|
||||
if options.normalizeValidatorOverride {
|
||||
normalize.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")},
|
||||
}
|
||||
}
|
||||
resolved, err := pipeline.ResolvePipeline(pipeline.PipelineProfile{
|
||||
ID: "assembled-dnd-spells",
|
||||
Input: pipeline.Binding("seriatim"),
|
||||
Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}},
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"spells": {Extract: pipeline.Binding(assembledSpellExtractorKey), Normalize: normalize},
|
||||
},
|
||||
Output: pipeline.Binding("json"),
|
||||
}, pipeline.ResolveOptions{}, catalogFromRegistries(components.registries))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
||||
}
|
||||
return components.registries, resolved, extractor
|
||||
}
|
||||
|
||||
type assembledSpellExtractor struct {
|
||||
mu sync.Mutex
|
||||
chunkIndexes []int
|
||||
unknownSpell bool
|
||||
}
|
||||
|
||||
func (e *assembledSpellExtractor) Key() string { return assembledSpellExtractorKey }
|
||||
|
||||
func (*assembledSpellExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
|
||||
func (e *assembledSpellExtractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.SpellList], error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, err
|
||||
}
|
||||
if req.Chunk == nil || req.Source == nil {
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, fmt.Errorf("assembled extractor requires source and chunk")
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.chunkIndexes = append(e.chunkIndexes, req.Chunk.Index)
|
||||
e.mu.Unlock()
|
||||
refOne := source.SourceRef{SourceID: req.Source.ID, StartUnitID: 1, EndUnitID: 1}
|
||||
refTwo := source.SourceRef{SourceID: req.Source.ID, StartUnitID: 2, EndUnitID: 2}
|
||||
if e.unknownSpell {
|
||||
if req.Chunk.Index == 0 {
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{
|
||||
Caster: "Aria", Spell: "Mysterious Burst", SourceRefs: []source.SourceRef{refOne},
|
||||
}}}}, nil
|
||||
}
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}}, nil
|
||||
}
|
||||
switch req.Chunk.Index {
|
||||
case 0:
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{
|
||||
Caster: " Aria \t", Spell: " cure wounds ", SourceRefs: []source.SourceRef{refTwo, refOne},
|
||||
}}}}, nil
|
||||
case 1:
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{
|
||||
{Caster: "aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{refOne, refTwo}},
|
||||
{Caster: "aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{refTwo}},
|
||||
}}}, nil
|
||||
default:
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, fmt.Errorf("unexpected assembled chunk index %d", req.Chunk.Index)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *assembledSpellExtractor) chunkIndexesSnapshot() []int {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return append([]int(nil), e.chunkIndexes...)
|
||||
}
|
||||
|
||||
func decodeAssembledOutput[T any](t *testing.T, files []contracts.OutputFile, name string) T {
|
||||
t.Helper()
|
||||
for _, file := range files {
|
||||
if file.Name != name {
|
||||
continue
|
||||
}
|
||||
var value T
|
||||
if err := json.Unmarshal(file.Bytes, &value); err != nil {
|
||||
t.Fatalf("decode %s: %v", name, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
t.Fatalf("output files = %#v, want %q", files, name)
|
||||
return *new(T)
|
||||
}
|
||||
371
internal/cli/cache_contract_test.go
Normal file
371
internal/cli/cache_contract_test.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkplan"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestRunChunkPlanModePrecedenceAndValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileMode string
|
||||
envMode string
|
||||
cliMode string
|
||||
wantStores int
|
||||
}{
|
||||
{name: "default", wantStores: 1},
|
||||
{name: "file", fileMode: "bypass"},
|
||||
{name: "environment", envMode: "bypass"},
|
||||
{name: "cli", envMode: "refresh", cliMode: "bypass"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
if tt.name == "default" {
|
||||
removeStateTestConfigLine(t, roots.config, " mode: auto\n")
|
||||
} else if tt.fileMode != "" {
|
||||
replaceStateTestConfigLine(t, roots.config, " mode: auto\n", " mode: "+tt.fileMode+"\n")
|
||||
}
|
||||
|
||||
var stores []string
|
||||
opts := newStateTestHarness().options()
|
||||
opts.LookupEnv = func(name string) (string, bool) {
|
||||
if name == "NOTARIUS_CACHE_CHUNK_PLANS_MODE" && tt.envMode != "" {
|
||||
return tt.envMode, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
opts.ChunkPlanStoreFactory = func(root string) (pipeline.ChunkPlanStore, error) {
|
||||
stores = append(stores, root)
|
||||
return chunkplan.NewFilesystemStore(root)
|
||||
}
|
||||
|
||||
args := []string{"run", "sample", "--config", roots.config, "--input", roots.input}
|
||||
if tt.cliMode != "" {
|
||||
args = append(args, "--chunk_cache", tt.cliMode)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := RunWithOptions(args, &stdout, &stderr, opts); code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertStateTestOutput(t, roots.output)
|
||||
if len(stores) != tt.wantStores {
|
||||
t.Fatalf("chunk plan store roots = %v, want %d stores", stores, tt.wantStores)
|
||||
}
|
||||
if tt.wantStores == 1 && stores[0] != roots.plans {
|
||||
t.Fatalf("chunk plan store root = %q, want %q", stores[0], roots.plans)
|
||||
}
|
||||
if tt.wantStores == 0 {
|
||||
assertAbsent(t, roots.plans)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid cli syntax is a usage error", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "invalid"}, &stdout, &stderr, newStateTestHarness().options())
|
||||
if code != 2 || stdout.Len() != 0 || stderr.Len() == 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertNoRunState(t, roots)
|
||||
})
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
fileConfig bool
|
||||
}{
|
||||
{name: "invalid environment mode"},
|
||||
{name: "invalid file mode", fileConfig: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
opts := newStateTestHarness().options()
|
||||
if tt.fileConfig {
|
||||
replaceStateTestConfigLine(t, roots.config, " mode: auto\n", " mode: invalid\n")
|
||||
} else {
|
||||
opts.LookupEnv = func(name string) (string, bool) {
|
||||
if name == "NOTARIUS_CACHE_CHUNK_PLANS_MODE" {
|
||||
return "invalid", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
|
||||
if code != 1 || stdout.Len() != 0 || stderr.Len() == 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertNoRunState(t, roots)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunChunkPlanRootSelectionAndFailures(t *testing.T) {
|
||||
t.Run("empty configured root uses the per-user cache root", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.plans))
|
||||
userCache := filepath.Join(t.TempDir(), "user-cache")
|
||||
var stores []string
|
||||
opts := newStateTestHarness().options()
|
||||
opts.UserCacheDir = func() (string, error) { return userCache, nil }
|
||||
opts.ChunkPlanStoreFactory = func(root string) (pipeline.ChunkPlanStore, error) {
|
||||
stores = append(stores, root)
|
||||
return chunkplan.NewFilesystemStore(root)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
wantRoot := filepath.Join(userCache, "notarius", "chunk-plans")
|
||||
if len(stores) != 1 || stores[0] != wantRoot {
|
||||
t.Fatalf("chunk plan store roots = %v, want [%q]", stores, wantRoot)
|
||||
}
|
||||
assertFile(t, filepath.Join(wantRoot, strings.TrimPrefix(stateTestDigest, "sha256:"), "plan.json"))
|
||||
assertAbsent(t, roots.plans)
|
||||
})
|
||||
|
||||
t.Run("bypass avoids default cache dependencies", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.plans))
|
||||
userCacheCalls := 0
|
||||
storeCalls := 0
|
||||
opts := newStateTestHarness().options()
|
||||
opts.UserCacheDir = func() (string, error) {
|
||||
userCacheCalls++
|
||||
return "", errors.New("user cache must not be resolved")
|
||||
}
|
||||
opts.ChunkPlanStoreFactory = func(string) (pipeline.ChunkPlanStore, error) {
|
||||
storeCalls++
|
||||
return nil, errors.New("chunk plan store must not be constructed")
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, &stdout, &stderr, opts)
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if userCacheCalls != 0 || storeCalls != 0 {
|
||||
t.Fatalf("user cache calls=%d store calls=%d, want none", userCacheCalls, storeCalls)
|
||||
}
|
||||
assertStateTestOutput(t, roots.output)
|
||||
assertAbsent(t, roots.plans)
|
||||
})
|
||||
|
||||
t.Run("user cache resolution failure has context and no output", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.plans))
|
||||
opts := newStateTestHarness().options()
|
||||
opts.UserCacheDir = func() (string, error) { return "", errors.New("cache home unavailable") }
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "resolve chunk plan root") || !strings.Contains(stderr.String(), "cache home unavailable") {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertNoRunState(t, roots)
|
||||
})
|
||||
|
||||
t.Run("store construction failure has context and no output", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
opts := newStateTestHarness().options()
|
||||
opts.ChunkPlanStoreFactory = func(root string) (pipeline.ChunkPlanStore, error) {
|
||||
return nil, fmt.Errorf("store unavailable")
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
|
||||
want := fmt.Sprintf("create chunk plan store at %q", roots.plans)
|
||||
if code != 1 || !strings.Contains(stderr.String(), want) || !strings.Contains(stderr.String(), "store unavailable") {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertNoRunState(t, roots)
|
||||
})
|
||||
|
||||
t.Run("checkpoint root resolution failure has context and no output", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.checkpoints))
|
||||
opts := newStateTestHarness().options()
|
||||
opts.UserCacheDir = func() (string, error) { return "", errors.New("checkpoint cache unavailable") }
|
||||
result := runStateTest(t, roots, opts, false, true, "bypass")
|
||||
if result.code != 1 || !strings.Contains(result.stderr, "resolve checkpoint root") || !strings.Contains(result.stderr, "checkpoint cache unavailable") {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
|
||||
}
|
||||
assertNoRunState(t, roots)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunAutoReusesPlanWhenRunInputsChange(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
data, err := os.ReadFile(roots.config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configText := replaceRequiredOnce(t, string(data), " chunk: test/chunk\n", ` chunk:
|
||||
module: test/chunk
|
||||
options:
|
||||
strategy: first
|
||||
`)
|
||||
configText = replaceRequiredOnce(t, configText, " output: test/output\n", ` other:
|
||||
extract: test/extract
|
||||
merge: test/merge
|
||||
normalize: test/normalize
|
||||
output: test/output
|
||||
`)
|
||||
if err := os.WriteFile(roots.config, []byte(configText), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
referencePath := filepath.Join(filepath.Dir(roots.input), "reference.txt")
|
||||
if err := os.WriteFile(referencePath, []byte("reference content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
harness := newStateTestHarness()
|
||||
var firstStdout, firstStderr bytes.Buffer
|
||||
first := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &firstStdout, &firstStderr, harness.options())
|
||||
if first != 0 {
|
||||
t.Fatalf("first run code=%d stdout=%q stderr=%q", first, firstStdout.String(), firstStderr.String())
|
||||
}
|
||||
configData, err := os.ReadFile(roots.config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configText = replaceRequiredOnce(t, string(configData), "strategy: first", "strategy: second")
|
||||
if err := os.WriteFile(roots.config, []byte(configText), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
second := RunWithOptions([]string{
|
||||
"run", "sample", "--config", roots.config, "--input", roots.input,
|
||||
"--only", "items", "--reference", "chunk.cache-reference=" + referencePath,
|
||||
}, &stdout, &stderr, harness.options())
|
||||
if second != 0 {
|
||||
t.Fatalf("second run code=%d stdout=%q stderr=%q", second, stdout.String(), stderr.String())
|
||||
}
|
||||
harness.mu.Lock()
|
||||
chunkCalls := harness.chunkCalls
|
||||
harness.mu.Unlock()
|
||||
if chunkCalls != 1 {
|
||||
t.Fatalf("chunk calls across changed run inputs = %d, want 1", chunkCalls)
|
||||
}
|
||||
assertFile(t, filepath.Join(roots.plans, strings.TrimPrefix(stateTestDigest, "sha256:"), "plan.json"))
|
||||
assertAnyFile(t, roots.output)
|
||||
}
|
||||
|
||||
func TestRunResumeSelectsConfiguredOrPerUserCheckpointRoot(t *testing.T) {
|
||||
for _, configured := range []bool{true, false} {
|
||||
name := "per-user root"
|
||||
if configured {
|
||||
name = "configured root"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
if !configured {
|
||||
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.checkpoints))
|
||||
}
|
||||
userCache := filepath.Join(t.TempDir(), "user-cache")
|
||||
userCacheCalls := 0
|
||||
opts := newStateTestHarness().options()
|
||||
opts.UserCacheDir = func() (string, error) {
|
||||
userCacheCalls++
|
||||
return userCache, nil
|
||||
}
|
||||
result := runStateTest(t, roots, opts, false, true, "bypass")
|
||||
if result.code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
|
||||
}
|
||||
wantRoot := roots.checkpoints
|
||||
wantCalls := 0
|
||||
if !configured {
|
||||
wantRoot = filepath.Join(userCache, "notarius", "checkpoints")
|
||||
wantCalls = 1
|
||||
}
|
||||
if userCacheCalls != wantCalls {
|
||||
t.Fatalf("user cache calls = %d, want %d", userCacheCalls, wantCalls)
|
||||
}
|
||||
assertAnyFile(t, wantRoot)
|
||||
if !configured {
|
||||
assertAbsent(t, roots.checkpoints)
|
||||
}
|
||||
assertStateTestOutput(t, roots.output)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("disabled avoids checkpoint root resolution", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
replaceStateTestConfigLine(t, roots.config, " enabled: true\n", " enabled: false\n")
|
||||
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.checkpoints))
|
||||
opts := newStateTestHarness().options()
|
||||
opts.UserCacheDir = func() (string, error) { return "", errors.New("checkpoint cache must not be resolved") }
|
||||
result := runStateTest(t, roots, opts, false, false, "bypass")
|
||||
if result.code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
|
||||
}
|
||||
assertStateTestOutput(t, roots.output)
|
||||
assertAbsent(t, roots.checkpoints)
|
||||
})
|
||||
|
||||
t.Run("resume requires enabled checkpoint recording", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
replaceStateTestConfigLine(t, roots.config, " enabled: true\n", " enabled: false\n")
|
||||
result := runStateTest(t, roots, newStateTestHarness().options(), true, true, "bypass")
|
||||
if result.code != 1 || !strings.Contains(result.stderr, "--resume requires cache.checkpoints.enabled: true") {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
|
||||
}
|
||||
assertNoRunState(t, roots)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigCommandsDoNotResolveRunState(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"config", "validate", "--config"},
|
||||
{"pipelines", "list", "--config"},
|
||||
} {
|
||||
name := strings.Join(args[:2], "-")
|
||||
t.Run(name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
opts := newStateTestHarness().options()
|
||||
opts.UserCacheDir = func() (string, error) { return "", errors.New("state root must not be resolved") }
|
||||
opts.ChunkPlanStoreFactory = func(string) (pipeline.ChunkPlanStore, error) {
|
||||
return nil, errors.New("chunk plan store must not be constructed")
|
||||
}
|
||||
command := append([]string(nil), args...)
|
||||
command = append(command, roots.config)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions(command, &stdout, &stderr, opts)
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertNoRunState(t, roots)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func replaceStateTestConfigLine(t *testing.T, path, old, new string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := replaceRequiredOnce(t, string(data), old, new)
|
||||
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func removeStateTestConfigLine(t *testing.T, path, line string) {
|
||||
replaceStateTestConfigLine(t, path, line, "")
|
||||
}
|
||||
|
||||
func assertNoRunState(t *testing.T, roots stateTestRoots) {
|
||||
t.Helper()
|
||||
assertAbsent(t, roots.output)
|
||||
assertAbsent(t, roots.plans)
|
||||
assertAbsent(t, roots.checkpoints)
|
||||
assertAbsent(t, roots.debug)
|
||||
}
|
||||
@@ -3,50 +3,54 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"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/modules/chunk/generic"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/extract/dnd/spells"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/input/seriatim"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/merge/appendorder"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/normalize/noop"
|
||||
jsonoutput "gitea.maximumdirect.net/eric/notarius/internal/modules/output/json"
|
||||
dndregister "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/register"
|
||||
genericregister "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/register"
|
||||
seriatimregister "gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/register"
|
||||
)
|
||||
|
||||
func productionRegistries() (pipeline.Registries, error) {
|
||||
type productionComponents struct {
|
||||
registries pipeline.Registries
|
||||
assets *llm.AssetRegistry
|
||||
}
|
||||
|
||||
func newProductionComponents() (productionComponents, error) {
|
||||
registries := pipeline.Registries{
|
||||
Inputs: pipeline.NewInputAdapterRegistry(),
|
||||
Chunkers: pipeline.NewChunkerRegistry(),
|
||||
ArtifactCodecs: pipeline.NewArtifactCodecRegistry(),
|
||||
Extractors: pipeline.NewExtractorRegistry(),
|
||||
Mergers: pipeline.NewMergerRegistry(),
|
||||
Normalizers: pipeline.NewNormalizerRegistry(),
|
||||
Validators: pipeline.NewValidatorRegistry(),
|
||||
ValidatorChains: pipeline.NewValidatorChainRegistry(),
|
||||
Outputs: pipeline.NewOutputEncoderRegistry(),
|
||||
}
|
||||
if err := seriatim.Register(registries.Inputs); err != nil {
|
||||
return pipeline.Registries{}, fmt.Errorf("register seriatim input: %w", err)
|
||||
assets := llm.NewAssetRegistry()
|
||||
registrars := []struct {
|
||||
name string
|
||||
register func(pipeline.Registries, *llm.AssetRegistry) error
|
||||
}{
|
||||
{name: "generic", register: genericregister.Register},
|
||||
{name: "seriatim", register: seriatimregister.Register},
|
||||
{name: "dnd", register: dndregister.Register},
|
||||
}
|
||||
if err := generic.Register(registries.Chunkers); err != nil {
|
||||
return pipeline.Registries{}, fmt.Errorf("register generic chunker: %w", err)
|
||||
for _, registrar := range registrars {
|
||||
if err := registrar.register(registries, assets); err != nil {
|
||||
return productionComponents{}, fmt.Errorf("register %s module family: %w", registrar.name, err)
|
||||
}
|
||||
if err := spells.Register(registries.Extractors); err != nil {
|
||||
return pipeline.Registries{}, fmt.Errorf("register dnd spells extractor: %w", err)
|
||||
}
|
||||
if err := appendorder.Register(registries.Mergers); err != nil {
|
||||
return pipeline.Registries{}, fmt.Errorf("register appendorder merger: %w", err)
|
||||
return productionComponents{registries: registries, assets: assets}, nil
|
||||
}
|
||||
if err := noop.Register(registries.Normalizers); err != nil {
|
||||
return pipeline.Registries{}, fmt.Errorf("register noop normalizer: %w", err)
|
||||
}
|
||||
if err := jsonoutput.Register(registries.Outputs); err != nil {
|
||||
return pipeline.Registries{}, fmt.Errorf("register json output encoder: %w", err)
|
||||
}
|
||||
return registries, nil
|
||||
|
||||
func productionRegistries() (pipeline.Registries, error) {
|
||||
components, err := newProductionComponents()
|
||||
return components.registries, err
|
||||
}
|
||||
|
||||
func productionCatalog() (pipeline.ModuleCatalog, error) {
|
||||
@@ -57,6 +61,11 @@ func productionCatalog() (pipeline.ModuleCatalog, error) {
|
||||
return catalogFromRegistries(registries), nil
|
||||
}
|
||||
|
||||
func productionPromptAssets() (*llm.AssetRegistry, error) {
|
||||
components, err := newProductionComponents()
|
||||
return components.assets, err
|
||||
}
|
||||
|
||||
func effectiveCatalog(opts Options) (pipeline.ModuleCatalog, error) {
|
||||
if !isEmptyCatalog(opts.Catalog) {
|
||||
return opts.Catalog, nil
|
||||
@@ -81,10 +90,12 @@ func catalogFromRegistries(registries pipeline.Registries) pipeline.ModuleCatalo
|
||||
return pipeline.ModuleCatalog{
|
||||
Inputs: registries.Inputs,
|
||||
Chunkers: registries.Chunkers,
|
||||
ArtifactCodecs: registries.ArtifactCodecs,
|
||||
Extractors: registries.Extractors,
|
||||
Mergers: registries.Mergers,
|
||||
Normalizers: registries.Normalizers,
|
||||
Validators: registries.Validators,
|
||||
ValidatorChains: registries.ValidatorChains,
|
||||
Outputs: registries.Outputs,
|
||||
}
|
||||
}
|
||||
@@ -93,10 +104,12 @@ func registriesFromCatalog(catalog pipeline.ModuleCatalog) pipeline.Registries {
|
||||
return pipeline.Registries{
|
||||
Inputs: catalog.Inputs,
|
||||
Chunkers: catalog.Chunkers,
|
||||
ArtifactCodecs: catalog.ArtifactCodecs,
|
||||
Extractors: catalog.Extractors,
|
||||
Mergers: catalog.Mergers,
|
||||
Normalizers: catalog.Normalizers,
|
||||
Validators: catalog.Validators,
|
||||
ValidatorChains: catalog.ValidatorChains,
|
||||
Outputs: catalog.Outputs,
|
||||
}
|
||||
}
|
||||
@@ -104,20 +117,24 @@ func registriesFromCatalog(catalog pipeline.ModuleCatalog) pipeline.Registries {
|
||||
func isEmptyCatalog(catalog pipeline.ModuleCatalog) bool {
|
||||
return catalog.Inputs == nil &&
|
||||
catalog.Chunkers == nil &&
|
||||
catalog.ArtifactCodecs == nil &&
|
||||
catalog.Extractors == nil &&
|
||||
catalog.Mergers == nil &&
|
||||
catalog.Normalizers == nil &&
|
||||
catalog.Validators == nil &&
|
||||
catalog.ValidatorChains == nil &&
|
||||
catalog.Outputs == nil
|
||||
}
|
||||
|
||||
func isEmptyRegistries(registries pipeline.Registries) bool {
|
||||
return registries.Inputs == nil &&
|
||||
registries.Chunkers == nil &&
|
||||
registries.ArtifactCodecs == nil &&
|
||||
registries.Extractors == nil &&
|
||||
registries.Mergers == nil &&
|
||||
registries.Normalizers == nil &&
|
||||
registries.Validators == nil &&
|
||||
registries.ValidatorChains == nil &&
|
||||
registries.Outputs == nil
|
||||
}
|
||||
|
||||
@@ -125,48 +142,39 @@ func productionLLMClientFactory(ctx context.Context, cfg config.Config, profileI
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
trimmedID := strings.TrimSpace(profileID)
|
||||
if trimmedID == "" {
|
||||
trimmedID = pipeline.DefaultLLMProfile
|
||||
}
|
||||
|
||||
profile, ok := cfg.LLMProfile(trimmedID)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("LLM profile %q is not configured", trimmedID)
|
||||
}
|
||||
clientCfg, err := cfg.OpenAICompatibleClientConfig(trimmedID)
|
||||
assets, err := productionPromptAssets()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
client, err := llm.NewOpenAICompatibleClient(clientCfg)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create LLM client for profile %q: %w", trimmedID, err)
|
||||
return buildProductionLLMClient(ctx, cfg, profileID, assets)
|
||||
}
|
||||
|
||||
scheduler, err := llm.NewScheduler(effectiveLLMConcurrency(cfg, profile))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create LLM scheduler for profile %q: %w", trimmedID, err)
|
||||
func productionLLMClientFactoryWithAssets(assets *llm.AssetRegistry) LLMClientFactory {
|
||||
return func(ctx context.Context, cfg config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
return buildProductionLLMClient(ctx, cfg, profileID, assets)
|
||||
}
|
||||
provider := strings.TrimSpace(profile.Provider)
|
||||
if provider == "" {
|
||||
provider = "openai-compatible"
|
||||
}
|
||||
metadata := []artifacts.LLMProfileManifest{
|
||||
{
|
||||
ID: trimmedID,
|
||||
Provider: provider,
|
||||
Model: strings.TrimSpace(profile.Model),
|
||||
},
|
||||
}
|
||||
return llm.NewScheduledClient(client, scheduler), metadata, nil
|
||||
}
|
||||
|
||||
func effectiveLLMConcurrency(cfg config.Config, profile config.LLMProfile) int {
|
||||
if profile.MaxConcurrency > 0 {
|
||||
return profile.MaxConcurrency
|
||||
func buildProductionLLMClient(ctx context.Context, cfg config.Config, profileID string, assets *llm.AssetRegistry) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM > 0 {
|
||||
return cfg.Concurrency.TotalLLM
|
||||
if assets == nil {
|
||||
return nil, nil, fmt.Errorf("production asset registry must not be nil")
|
||||
}
|
||||
return 1
|
||||
recorder := llm.NewLLMProfileRecorder()
|
||||
client, err := llm.NewScriptoriumClient(llm.ScriptoriumClientConfig{
|
||||
ProfileDir: cfg.Scriptorium.ProfileDir,
|
||||
ProfileFile: cfg.Scriptorium.ProfileFile,
|
||||
Assets: assets,
|
||||
Recorder: recorder,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create Scriptorium-backed LLM client: %w", err)
|
||||
}
|
||||
scheduler, err := llm.NewScheduler(cfg.Concurrency.TotalLLM)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create LLM scheduler: %w", err)
|
||||
}
|
||||
return llm.NewScheduledClient(client, scheduler), nil, nil
|
||||
}
|
||||
|
||||
240
internal/cli/command_contract_test.go
Normal file
240
internal/cli/command_contract_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCommandHelpSpellingsWriteUsageToStdout(t *testing.T) {
|
||||
tests := [][]string{nil, {"help"}, {"--help"}, {"-h"}}
|
||||
for _, args := range tests {
|
||||
name := "no arguments"
|
||||
if len(args) > 0 {
|
||||
name = args[0]
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions(args, &stdout, &stderr, commandContractOptions(t))
|
||||
if code != 0 || !strings.Contains(stdout.String(), "Usage:") || stderr.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandSyntaxErrorsUseStderrAndExitTwo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "unknown command", args: []string{"unknown"}, want: "unknown command"},
|
||||
{name: "missing config subcommand", args: []string{"config"}, want: "config requires a subcommand"},
|
||||
{name: "unknown pipelines subcommand", args: []string{"pipelines", "unknown"}, want: "unknown pipelines subcommand"},
|
||||
{name: "malformed run flag", args: []string{"run", "demo", "--chunk_cache", "invalid"}, want: "not supported"},
|
||||
{name: "unknown flag", args: []string{"config", "validate", "--unknown"}, want: "flag provided but not defined"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions(tt.args, &stdout, &stderr, commandContractOptions(t))
|
||||
if code != 2 || !strings.Contains(stderr.String(), tt.want) || stdout.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiscoveryPrefersExplicitPathThenEnvironment(t *testing.T) {
|
||||
explicit := writeCommandConfig(t, "explicit", "alpha")
|
||||
environment := writeCommandConfig(t, "environment", "beta")
|
||||
lookup := func(name string) (string, bool) {
|
||||
if name == "NOTARIUS_CONFIG" {
|
||||
return environment, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"pipelines", "list", "--config", explicit}, &stdout, &stderr, commandContractOptionsWithLookup(t, lookup))
|
||||
if code != 0 || stdout.String() != "alpha\nexplicit\n" || stderr.Len() != 0 {
|
||||
t.Fatalf("explicit config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"pipelines", "list"}, &stdout, &stderr, commandContractOptionsWithLookup(t, lookup))
|
||||
if code != 0 || stdout.String() != "beta\nenvironment\n" || stderr.Len() != 0 {
|
||||
t.Fatalf("environment config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiscoveryUsesCompiledDefaultOnlyWhenAvailable(t *testing.T) {
|
||||
info, statErr := os.Stat(defaultConfigPath)
|
||||
if statErr != nil && !os.IsNotExist(statErr) {
|
||||
t.Fatalf("stat compiled default config: %v", statErr)
|
||||
}
|
||||
if statErr == nil && !info.Mode().IsRegular() {
|
||||
t.Skipf("compiled default config has unexpected host state: %s", info.Mode())
|
||||
}
|
||||
|
||||
path, err := discoverConfigPath("", commandContractOptions(t))
|
||||
if statErr == nil {
|
||||
if err != nil || path != defaultConfigPath {
|
||||
t.Fatalf("discoverConfigPath() = %q, %v; want compiled default", path, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "config file not found") {
|
||||
t.Fatalf("discoverConfigPath() error = %v, want documented not-found context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLoadingFailuresReturnOneWithPathContext(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "missing.yml")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", missing}, &stdout, &stderr, commandContractOptions(t))
|
||||
if code != 1 || !strings.Contains(stderr.String(), missing) || stdout.Len() != 0 {
|
||||
t.Fatalf("missing config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
malformed := filepath.Join(t.TempDir(), "malformed.yml")
|
||||
if err := os.WriteFile(malformed, []byte("version: [\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"config", "validate", "--config", malformed}, &stdout, &stderr, commandContractOptions(t))
|
||||
if code != 1 || !strings.Contains(stderr.String(), malformed) || !strings.Contains(stderr.String(), "parse config file") || stdout.Len() != 0 {
|
||||
t.Fatalf("malformed config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidateResolvesPipelineAndChecksSelection(t *testing.T) {
|
||||
configPath := writeResolvableCommandConfig(t)
|
||||
options := commandContractOptions(t)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "spells"}, &stdout, &stderr, options)
|
||||
if code != 0 || !strings.Contains(stdout.String(), "valid for pipeline \"demo\"") || stderr.Len() != 0 {
|
||||
t.Fatalf("valid resolution: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "missing"}, &stdout, &stderr, options)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "pipeline \"missing\"") {
|
||||
t.Fatalf("unknown pipeline: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "missing"}, &stdout, &stderr, options)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "lane \"missing\"") {
|
||||
t.Fatalf("unknown lane: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--only", "spells"}, &stdout, &stderr, options)
|
||||
if code != 2 || !strings.Contains(stderr.String(), "--only requires --pipeline") {
|
||||
t.Fatalf("missing pipeline for only: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "spells,,other"}, &stdout, &stderr, options)
|
||||
if code != 2 || !strings.Contains(stderr.String(), "--only must contain") {
|
||||
t.Fatalf("malformed only: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelinesListSortsNormalizedIDsInTextAndJSON(t *testing.T) {
|
||||
configPath := writeCommandConfig(t, " zeta ", "alpha")
|
||||
options := commandContractOptions(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"pipelines", "list", "--config", configPath}, &stdout, &stderr, options)
|
||||
if code != 0 || stdout.String() != "alpha\nzeta\n" || stderr.Len() != 0 {
|
||||
t.Fatalf("text list: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"pipelines", "list", "--config", configPath, "--json"}, &stdout, &stderr, options)
|
||||
var payload struct {
|
||||
Pipelines []string `json:"pipelines"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("JSON list = %q: %v", stdout.String(), err)
|
||||
}
|
||||
if code != 0 || len(payload.Pipelines) != 2 || payload.Pipelines[0] != "alpha" || payload.Pipelines[1] != "zeta" || stderr.Len() != 0 {
|
||||
t.Fatalf("JSON list: code=%d payload=%#v stderr=%q", code, payload, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovedStructuralFlagsAndRuntimeFailuresKeepExitClasses(t *testing.T) {
|
||||
configPath := writeResolvableCommandConfig(t)
|
||||
options := commandContractOptions(t)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "demo", "--input", "missing-input", "--config", configPath, "--diagnostics-dir", t.TempDir()}, &stdout, &stderr, options)
|
||||
if code != 2 || !strings.Contains(stderr.String(), "flag provided but not defined") {
|
||||
t.Fatalf("removed flag: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"run", "missing", "--input", "missing-input", "--config", configPath, "--chunk_cache", "bypass"}, &stdout, &stderr, options)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "pipeline \"missing\"") || stdout.Len() != 0 {
|
||||
t.Fatalf("valid-runtime failure: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func commandContractOptions(t *testing.T) Options {
|
||||
return commandContractOptionsWithLookup(t, emptyLookup)
|
||||
}
|
||||
|
||||
func commandContractOptionsWithLookup(t *testing.T, lookup func(string) (string, bool)) Options {
|
||||
t.Helper()
|
||||
components, err := newProductionComponents()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Options{
|
||||
Catalog: catalogFromRegistries(components.registries),
|
||||
Registries: components.registries,
|
||||
LookupEnv: lookup,
|
||||
}
|
||||
}
|
||||
|
||||
func writeCommandConfig(t *testing.T, firstID, secondID string) string {
|
||||
t.Helper()
|
||||
content := fmt.Sprintf("version: 3\npipelines:\n %q:\n input: seriatim\n %q:\n input: seriatim\n", firstID, secondID)
|
||||
return writeCommandConfigContent(t, content)
|
||||
}
|
||||
|
||||
func writeResolvableCommandConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
return writeCommandConfigContent(t, `version: 3
|
||||
pipelines:
|
||||
demo:
|
||||
input: seriatim
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
`)
|
||||
}
|
||||
|
||||
func writeCommandConfigContent(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
16
internal/cli/contract_test_helpers_test.go
Normal file
16
internal/cli/contract_test_helpers_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func replaceRequiredOnce(t *testing.T, input, old, replacement string) string {
|
||||
t.Helper()
|
||||
if count := strings.Count(input, old); count != 1 {
|
||||
t.Fatalf("replacement marker %q occurs %d times, want exactly once", old, count)
|
||||
}
|
||||
return strings.Replace(input, old, replacement, 1)
|
||||
}
|
||||
|
||||
func emptyLookup(string) (string, bool) { return "", false }
|
||||
174
internal/cli/dnd_combat_contract_test.go
Normal file
174
internal/cli/dnd_combat_contract_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||
)
|
||||
|
||||
func TestProductionCombatConfigurationResolvesTypedLane(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
cfg := productionCombatContractConfig()
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||
}
|
||||
if effective.ResolvedPipeline.Chunk.Module != pipeline.DefaultChunkModule {
|
||||
t.Fatalf("chunk module = %q, want %q", effective.ResolvedPipeline.Chunk.Module, pipeline.DefaultChunkModule)
|
||||
}
|
||||
if len(effective.ResolvedPipeline.Steps[0].ArtifactLanes) != 1 {
|
||||
t.Fatalf("artifact lanes = %#v, want one combat lane", effective.ResolvedPipeline.Steps[0].ArtifactLanes)
|
||||
}
|
||||
lane := effective.ResolvedPipeline.Steps[0].ArtifactLanes[0]
|
||||
if lane.ID != "combat" || lane.ArtifactKind != dnd.CombatTurnListKind || lane.Extract.Module != combatextract.Key || lane.Extract.Retries != 2 || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != combatnormalize.Key {
|
||||
t.Fatalf("resolved combat lane = %#v, want typed production composition", lane)
|
||||
}
|
||||
|
||||
catalog := catalogFromRegistries(components.registries)
|
||||
extractSpec, ok := catalog.Extractors.Spec(combatextract.Key)
|
||||
if !ok || !reflect.DeepEqual(extractSpec.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(extractSpec.Provides, []string{"dnd.combat_turns"}) {
|
||||
t.Fatalf("combat extractor spec = %#v, want source and artifact capabilities", extractSpec)
|
||||
}
|
||||
normalizeSpec, ok := catalog.Normalizers.SpecForArtifact(combatnormalize.Key, dnd.CombatTurnListKind)
|
||||
if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) {
|
||||
t.Fatalf("combat normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec)
|
||||
}
|
||||
mergeSpec, ok := catalog.Mergers.SpecForArtifact(pipeline.DefaultMergeModule, dnd.CombatTurnListKind)
|
||||
if !ok || !reflect.DeepEqual(mergeSpec.Provides, []string{"merged"}) {
|
||||
t.Fatalf("combat merger spec = %#v, want merged capability", mergeSpec)
|
||||
}
|
||||
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.CombatTurnListKind)
|
||||
if !ok || codecSpec.Schema.ID != "notarius.dnd.combat_turns" || codecSpec.Schema.Version != "v1" {
|
||||
t.Fatalf("combat codec spec = %#v, want compatible durable schema", codecSpec)
|
||||
}
|
||||
if !hasReferenceSlot(extractSpec.ReferenceSlots, "npcs") || !hasReferenceSlot(normalizeSpec.ReferenceSlots, "npcs") {
|
||||
t.Fatalf("combat reference slots = %#v / %#v, want stage-local NPC slots", extractSpec.ReferenceSlots, normalizeSpec.ReferenceSlots)
|
||||
}
|
||||
|
||||
wantExtractChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||
}
|
||||
wantNormalizeChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||
pipeline.Binding("normalize/dnd/combat-turns/invariants"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||
}
|
||||
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, wantExtractChain) {
|
||||
t.Fatalf("combat extract chain = %#v, want %#v", got, wantExtractChain)
|
||||
}
|
||||
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, wantNormalizeChain) {
|
||||
t.Fatalf("combat normalize chain = %#v, want %#v", got, wantNormalizeChain)
|
||||
}
|
||||
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageMerge, pipeline.DefaultMergeModule); len(got) != 0 {
|
||||
t.Fatalf("combat merge chain = %#v, want empty", got)
|
||||
}
|
||||
|
||||
bound, err := cfg.Resolve(config.ResolveInput{
|
||||
PipelineID: "dnd-combat",
|
||||
Catalog: catalog,
|
||||
ReferenceOverrides: []pipeline.ReferenceBinding{
|
||||
{Stage: pipeline.StageExtract, LaneID: "combat", SlotName: "npcs", Source: "npc-run/lanes/npcs.json", BindingSource: contracts.ReferenceBindingSourceCLI},
|
||||
{Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "npcs", Source: "npc-run/lanes/npcs.json", BindingSource: contracts.ReferenceBindingSourceCLI},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve(bound references) error = %v, want nil", err)
|
||||
}
|
||||
boundLane := bound.ResolvedPipeline.Steps[0].ArtifactLanes[0]
|
||||
if len(boundLane.ExtractReferences.Bindings) != 1 || len(boundLane.NormalizeReferences.Bindings) != 1 || boundLane.ExtractReferences.Bindings[0].SlotName != "npcs" || boundLane.NormalizeReferences.Bindings[0].SlotName != "npcs" {
|
||||
t.Fatalf("bound combat references = %#v / %#v, want one independent NPC binding per stage", boundLane.ExtractReferences, boundLane.NormalizeReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionCombatConfigurationRejectsLooseOptionsAndLaneValidators(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
resolve := func(mutate func(*pipeline.PipelineProfile)) error {
|
||||
cfg := productionCombatContractConfig()
|
||||
profile := cfg.Pipelines["dnd-combat"]
|
||||
mutate(&profile)
|
||||
cfg.Pipelines["dnd-combat"] = profile
|
||||
_, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)})
|
||||
return err
|
||||
}
|
||||
if err := resolve(func(profile *pipeline.PipelineProfile) {
|
||||
lane := profile.Artifacts["combat"]
|
||||
lane.Extract.Options = map[string]any{"unexpected": true}
|
||||
profile.Artifacts["combat"] = lane
|
||||
}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
||||
t.Fatalf("unknown extractor option error = %v, want strict option rejection", err)
|
||||
}
|
||||
if err := resolve(func(profile *pipeline.PipelineProfile) {
|
||||
lane := profile.Artifacts["combat"]
|
||||
lane.Normalize.Options = map[string]any{"unexpected": true}
|
||||
profile.Artifacts["combat"] = lane
|
||||
}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
||||
t.Fatalf("unknown normalizer option error = %v, want strict option rejection", err)
|
||||
}
|
||||
if err := resolve(func(profile *pipeline.PipelineProfile) {
|
||||
lane := profile.Artifacts["combat"]
|
||||
lane.Validators = []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}
|
||||
profile.Artifacts["combat"] = lane
|
||||
}); err == nil || !strings.Contains(err.Error(), "artifact lane level") {
|
||||
t.Fatalf("lane-level validator error = %v, want invalid placement rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionCombatConfigurationResolvesTypedUnconditionalValidators(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
cfg := productionCombatContractConfig()
|
||||
profile := cfg.Pipelines["dnd-combat"]
|
||||
lane := profile.Artifacts["combat"]
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{Set: true, Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}}
|
||||
lane.Normalize.Validators = pipeline.ValidatorOverride{Set: true, Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_reject")}}
|
||||
profile.Artifacts["combat"] = lane
|
||||
cfg.Pipelines["dnd-combat"] = profile
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v, want typed unconditional validators to resolve", err)
|
||||
}
|
||||
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}) {
|
||||
t.Fatalf("extract override chain = %#v, want typed always-accept", got)
|
||||
}
|
||||
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, []pipeline.ModuleBinding{pipeline.Binding("generic/always_reject")}) {
|
||||
t.Fatalf("normalize override chain = %#v, want typed always-reject", got)
|
||||
}
|
||||
}
|
||||
|
||||
func productionCombatContractConfig() config.Config {
|
||||
cfg := config.Default()
|
||||
cfg.Pipelines["dnd-combat"] = pipeline.PipelineProfile{
|
||||
ID: "dnd-combat",
|
||||
Input: pipeline.Binding("seriatim"),
|
||||
Chunk: pipeline.Binding(pipeline.DefaultChunkModule),
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"combat": {
|
||||
Extract: pipeline.ModuleBinding{Module: combatextract.Key, Retries: 2},
|
||||
Normalize: pipeline.Binding(combatnormalize.Key),
|
||||
},
|
||||
},
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func hasReferenceSlot(slots []contracts.ReferenceSlot, name string) bool {
|
||||
for _, slot := range slots {
|
||||
if slot.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
135
internal/cli/dnd_interactions_contract_test.go
Normal file
135
internal/cli/dnd_interactions_contract_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions"
|
||||
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
|
||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||
interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions"
|
||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||
)
|
||||
|
||||
func TestProductionNPCInteractionPipelineResolvesAndPrepares(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
resolved, err := pipeline.ResolvePipeline(npcInteractionProfile(pipeline.GeneratedReference("npcs", "npcs")), pipeline.ResolveOptions{}, catalogFromRegistries(components.registries))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePipeline() error = %v", err)
|
||||
}
|
||||
if len(resolved.Steps) != 2 || len(resolved.Steps[1].ArtifactLanes) != 1 {
|
||||
t.Fatalf("resolved pipeline = %#v", resolved)
|
||||
}
|
||||
lane := resolved.Steps[1].ArtifactLanes[0]
|
||||
if lane.ArtifactKind != dnd.NPCInteractionListKind || lane.Extract.Module != interactionextract.Key || lane.Normalize.Module != interactionnormalize.Key {
|
||||
t.Fatalf("interaction lane = %#v", lane)
|
||||
}
|
||||
for _, bindings := range [][]pipeline.ReferenceBinding{lane.ExtractReferences.Bindings, lane.NormalizeReferences.Bindings} {
|
||||
if len(bindings) != 1 || bindings[0].SlotName != "npcs" || bindings[0].Artifact == nil || bindings[0].Artifact.Step != "npcs" || bindings[0].Artifact.Lane != "npcs" {
|
||||
t.Fatalf("generated bindings = %#v", bindings)
|
||||
}
|
||||
}
|
||||
if _, err := pipeline.Prepare(resolved, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil {
|
||||
t.Fatalf("Prepare() error = %v", err)
|
||||
}
|
||||
|
||||
catalog := catalogFromRegistries(components.registries)
|
||||
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCInteractionListKind)
|
||||
if !ok || codecSpec.Schema.ID != interactioncodec.SchemaID || codecSpec.Schema.Version != interactioncodec.SchemaVersion {
|
||||
t.Fatalf("NPC interaction codec spec = %#v", codecSpec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionNPCInteractionReferencesRequireEarlierCompatibleProducer(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
catalog := catalogFromRegistries(components.registries)
|
||||
laterProfile := npcInteractionProfile(pipeline.GeneratedReference("npcs", "npcs"))
|
||||
laterProfile.Steps[0].ID = "seed"
|
||||
laterProfile.Steps[0].Artifacts["seed"] = laterProfile.Steps[0].Artifacts["npcs"]
|
||||
delete(laterProfile.Steps[0].Artifacts, "npcs")
|
||||
laterProfile.Steps = append(laterProfile.Steps, pipeline.PipelineStepProfile{ID: "future", Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"npcs": {Extract: pipeline.Binding(npcextract.Key), Normalize: pipeline.Binding(npcnormalize.Key)},
|
||||
}})
|
||||
laterProfile.Steps[1].References["npcs"] = pipeline.GeneratedReference("future", "npcs")
|
||||
tests := []struct {
|
||||
name string
|
||||
profile pipeline.PipelineProfile
|
||||
want string
|
||||
}{
|
||||
{name: "missing", profile: npcInteractionProfile(pipeline.ReferenceSource{}), want: "source must not be empty"},
|
||||
{name: "same step", profile: npcInteractionProfile(pipeline.GeneratedReference("interactions", "interactions")), want: "earlier step"},
|
||||
{name: "later step", profile: laterProfile, want: "earlier step"},
|
||||
{name: "wrong artifact kind", profile: npcInteractionProfile(pipeline.GeneratedReference("npcs", "npcs")), want: "does not accept artifact kind"},
|
||||
}
|
||||
tests[3].profile.Steps[0].Artifacts["npcs"] = pipeline.ArtifactLaneProfile{Extract: pipeline.Binding("dnd/spells")}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := pipeline.ResolvePipeline(test.profile, pipeline.ResolveOptions{}, catalog)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("ResolvePipeline() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionNPCInteractionReferencesRejectIncompatibleExternalRegistries(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
catalog := catalogFromRegistries(components.registries)
|
||||
root := t.TempDir()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
file string
|
||||
content string
|
||||
prepare bool
|
||||
want string
|
||||
}{
|
||||
{name: "media type", file: "registry.txt", content: "not JSON", want: "media type"},
|
||||
{name: "artifact schema", file: "registry.json", content: `{"npcs":[{"name":"missing required fields"}]}`, prepare: true, want: "NPC registry"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path := filepath.Join(root, test.file)
|
||||
if err := os.WriteFile(path, []byte(test.content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := pipeline.ResolvePipeline(npcInteractionProfile(pipeline.ExternalReference(path)), pipeline.ResolveOptions{}, catalog)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePipeline() error = %v", err)
|
||||
}
|
||||
materialized, _, err := pipeline.MaterializeReferences(resolved, catalog, pipeline.ReferenceMaterializationOptions{})
|
||||
if !test.prepare {
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("MaterializeReferences() error = %v, want %q", err, test.want)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("MaterializeReferences() error = %v", err)
|
||||
}
|
||||
if _, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Prepare() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func npcInteractionProfile(reference pipeline.ReferenceSource) pipeline.PipelineProfile {
|
||||
profile := pipeline.PipelineProfile{
|
||||
ID: "dnd-npc-interactions",
|
||||
Input: pipeline.Binding("seriatim"),
|
||||
Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}},
|
||||
Output: pipeline.Binding("json"),
|
||||
Steps: []pipeline.PipelineStepProfile{
|
||||
{ID: "npcs", Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"npcs": {Extract: pipeline.Binding(npcextract.Key), Normalize: pipeline.Binding(npcnormalize.Key)},
|
||||
}},
|
||||
{ID: "interactions", References: map[string]pipeline.ReferenceSource{"npcs": reference}, Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"interactions": {Extract: pipeline.Binding(interactionextract.Key), Normalize: pipeline.Binding(interactionnormalize.Key)},
|
||||
}},
|
||||
},
|
||||
}
|
||||
return profile
|
||||
}
|
||||
151
internal/cli/dnd_npc_contract_test.go
Normal file
151
internal/cli/dnd_npc_contract_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||
)
|
||||
|
||||
func TestProductionNPCConfigurationResolvesTypedLane(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
catalog := catalogFromRegistries(components.registries)
|
||||
cfg := productionNPCContractConfig()
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalog})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||
}
|
||||
if effective.ResolvedPipeline.Chunk.Module != pipeline.DefaultChunkModule {
|
||||
t.Fatalf("chunk module = %q, want %q", effective.ResolvedPipeline.Chunk.Module, pipeline.DefaultChunkModule)
|
||||
}
|
||||
if len(effective.ResolvedPipeline.Steps[0].ArtifactLanes) != 1 {
|
||||
t.Fatalf("artifact lanes = %#v, want one NPC lane", effective.ResolvedPipeline.Steps[0].ArtifactLanes)
|
||||
}
|
||||
lane := effective.ResolvedPipeline.Steps[0].ArtifactLanes[0]
|
||||
if lane.ID != "npcs" || lane.ArtifactKind != dnd.NPCListKind || lane.Extract.Module != npcextract.Key || lane.Extract.Retries != 2 || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != npcnormalize.Key {
|
||||
t.Fatalf("resolved NPC lane = %#v, want typed production composition", lane)
|
||||
}
|
||||
if len(lane.ExtractReferences.Bindings) != 0 || len(lane.NormalizeReferences.Bindings) != 0 {
|
||||
t.Fatalf("unbound NPC references = %#v / %#v, want none", lane.ExtractReferences, lane.NormalizeReferences)
|
||||
}
|
||||
|
||||
extractSpec, ok := catalog.Extractors.Spec(npcextract.Key)
|
||||
if !ok || !reflect.DeepEqual(extractSpec.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(extractSpec.Provides, []string{"dnd.npcs"}) {
|
||||
t.Fatalf("NPC extractor spec = %#v, want source and artifact capabilities", extractSpec)
|
||||
}
|
||||
mergeSpec, ok := catalog.Mergers.SpecForArtifact(pipeline.DefaultMergeModule, dnd.NPCListKind)
|
||||
if !ok || !reflect.DeepEqual(mergeSpec.Provides, []string{"merged"}) {
|
||||
t.Fatalf("NPC merger spec = %#v, want merged capability", mergeSpec)
|
||||
}
|
||||
normalizeSpec, ok := catalog.Normalizers.SpecForArtifact(npcnormalize.Key, dnd.NPCListKind)
|
||||
if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) {
|
||||
t.Fatalf("NPC normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec)
|
||||
}
|
||||
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCListKind)
|
||||
if !ok || codecSpec.Kind != dnd.NPCListKind || codecSpec.Schema.ID != npccodec.SchemaID || codecSpec.Schema.Version != npccodec.SchemaVersion {
|
||||
t.Fatalf("NPC codec spec = %#v, want typed v1 durable schema", codecSpec)
|
||||
}
|
||||
|
||||
wantExtractChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("extract/dnd/npcs/shape"),
|
||||
pipeline.Binding("extract/dnd/npcs/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/npcs/source_relatedness"),
|
||||
}
|
||||
wantNormalizeChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("extract/dnd/npcs/shape"),
|
||||
pipeline.Binding("normalize/dnd/npcs/identity"),
|
||||
pipeline.Binding("extract/dnd/npcs/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/npcs/source_relatedness"),
|
||||
}
|
||||
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, npcextract.Key); !reflect.DeepEqual(got, wantExtractChain) {
|
||||
t.Fatalf("NPC extract chain = %#v, want %#v", got, wantExtractChain)
|
||||
}
|
||||
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageNormalize, npcnormalize.Key); !reflect.DeepEqual(got, wantNormalizeChain) {
|
||||
t.Fatalf("NPC normalize chain = %#v, want %#v", got, wantNormalizeChain)
|
||||
}
|
||||
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageMerge, pipeline.DefaultMergeModule); len(got) != 0 {
|
||||
t.Fatalf("NPC merge chain = %#v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionNPCConfigurationValidatesOptionsReferencesAndPlacement(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
resolve := func(mutate func(*pipeline.PipelineProfile)) error {
|
||||
cfg := productionNPCContractConfig()
|
||||
profile := cfg.Pipelines["dnd-session"]
|
||||
mutate(&profile)
|
||||
cfg.Pipelines["dnd-session"] = profile
|
||||
_, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalogFromRegistries(components.registries)})
|
||||
return err
|
||||
}
|
||||
|
||||
if err := resolve(func(profile *pipeline.PipelineProfile) {
|
||||
lane := profile.Artifacts["npcs"]
|
||||
lane.Extract.Options = map[string]any{"unexpected": true}
|
||||
profile.Artifacts["npcs"] = lane
|
||||
}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
||||
t.Fatalf("unknown extractor option error = %v, want strict option rejection", err)
|
||||
}
|
||||
if err := resolve(func(profile *pipeline.PipelineProfile) {
|
||||
lane := profile.Artifacts["npcs"]
|
||||
lane.Normalize.Options = map[string]any{"unexpected": true}
|
||||
profile.Artifacts["npcs"] = lane
|
||||
}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
||||
t.Fatalf("unknown normalizer option error = %v, want strict option rejection", err)
|
||||
}
|
||||
if err := resolve(func(profile *pipeline.PipelineProfile) {
|
||||
profile.References = pipeline.ExternalReferenceMap(map[string]string{
|
||||
"players": "players.txt",
|
||||
"party": "party.txt",
|
||||
"glossary": "glossary.txt",
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatalf("optional NPC references error = %v, want resolution success", err)
|
||||
}
|
||||
if err := resolve(func(profile *pipeline.PipelineProfile) {
|
||||
lane := profile.Artifacts["npcs"]
|
||||
lane.Validators = []pipeline.ModuleBinding{pipeline.Binding("normalize/dnd/npcs/identity")}
|
||||
profile.Artifacts["npcs"] = lane
|
||||
}); err == nil || !strings.Contains(err.Error(), "artifact lane level") {
|
||||
t.Fatalf("lane-level validator error = %v, want invalid placement rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func productionNPCContractConfig() config.Config {
|
||||
cfg := config.Default()
|
||||
cfg.Pipelines["dnd-session"] = pipeline.PipelineProfile{
|
||||
ID: "dnd-session",
|
||||
Input: pipeline.Binding("seriatim"),
|
||||
Chunk: pipeline.Binding(pipeline.DefaultChunkModule),
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"npcs": {
|
||||
Extract: pipeline.ModuleBinding{Module: npcextract.Key, Retries: 2},
|
||||
Normalize: pipeline.Binding(npcnormalize.Key),
|
||||
},
|
||||
},
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func validatorChain(resolved pipeline.ResolvedPipeline, stage pipeline.ModuleStage, module string) []pipeline.ModuleBinding {
|
||||
for _, chain := range resolved.ValidatorChains {
|
||||
if chain.Stage == stage && chain.ModuleKey == module {
|
||||
bindings := make([]pipeline.ModuleBinding, len(chain.Validators))
|
||||
for index, validator := range chain.Validators {
|
||||
bindings[index] = validator.Binding
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
111
internal/cli/dnd_scene_descriptions_contract_test.go
Normal file
111
internal/cli/dnd_scene_descriptions_contract_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"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"
|
||||
scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
|
||||
sceneextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions"
|
||||
scenenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions"
|
||||
)
|
||||
|
||||
func TestProductionSceneDescriptionWorkflow(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
cfg := config.Default()
|
||||
cfg.Pipelines["scene-descriptions"] = pipeline.PipelineProfile{
|
||||
ID: "scene-descriptions",
|
||||
Input: pipeline.Binding("seriatim"),
|
||||
Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}},
|
||||
Output: pipeline.Binding("json"),
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"scene-descriptions": {
|
||||
Extract: pipeline.Binding(sceneextract.Key),
|
||||
Normalize: pipeline.Binding(scenenormalize.Key),
|
||||
},
|
||||
},
|
||||
}
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "scene-descriptions", Catalog: catalogFromRegistries(components.registries)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
lane := effective.ResolvedPipeline.Steps[0].ArtifactLanes[0]
|
||||
if lane.ArtifactKind != dnd.SceneDescriptionListKind || lane.Extract.Module != sceneextract.Key || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != scenenormalize.Key {
|
||||
t.Fatalf("resolved lane = %#v, want production scene-description composition", lane)
|
||||
}
|
||||
if len(lane.ExtractReferences.Bindings) != 0 || len(lane.NormalizeReferences.Bindings) != 0 {
|
||||
t.Fatalf("resolved references = %#v / %#v, want no generated or required references", lane.ExtractReferences, lane.NormalizeReferences)
|
||||
}
|
||||
|
||||
prepared, err := pipeline.Prepare(effective.ResolvedPipeline, components.registries, pipeline.ModuleDependencies{LLM: sceneDescriptionLLM{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare() error = %v", err)
|
||||
}
|
||||
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
|
||||
Prepared: prepared,
|
||||
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
|
||||
ChunkCacheMode: pipeline.ChunkCacheBypass,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if output.Manifest.ValidationStatus != "approved" || len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 {
|
||||
t.Fatalf("run output = %#v, want one approved normalized artifact", output)
|
||||
}
|
||||
normalizedOutput := output.NormalizeOutputs[0]
|
||||
if normalizedOutput.NormalizerKey != scenenormalize.Key || normalizedOutput.Artifact.Kind != dnd.SceneDescriptionListKind || normalizedOutput.Artifact.Schema.ID != scenecodec.SchemaID || normalizedOutput.Artifact.Schema.Name != scenecodec.SchemaName || normalizedOutput.Artifact.Schema.Version != scenecodec.SchemaVersion {
|
||||
t.Fatalf("normalized output = %#v, want registered durable scene-description schema", normalizedOutput)
|
||||
}
|
||||
|
||||
var value dnd.SceneDescriptionList
|
||||
if err := json.Unmarshal(normalizedOutput.Artifact.Content, &value); err != nil {
|
||||
t.Fatalf("decode normalized artifact: %v", err)
|
||||
}
|
||||
want := dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{
|
||||
{ID: "chunk-000001", SourceRef: source.SourceRef{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}, Kind: dnd.SceneKindNarrative, Title: "Aria casts Cure Wounds", Summary: "Aria casts Cure Wounds."},
|
||||
{ID: "chunk-000002", SourceRef: source.SourceRef{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}, Kind: dnd.SceneKindCombat, Title: "Bandit mage casts Shield", Summary: "The bandit mage casts Shield."},
|
||||
}}
|
||||
if !reflect.DeepEqual(value, want) {
|
||||
t.Fatalf("normalized scene descriptions = %#v, want %#v", value, want)
|
||||
}
|
||||
durable := decodeAssembledOutput[dnd.SceneDescriptionList](t, output.OutputFiles, "lanes/scene-descriptions.json")
|
||||
if !reflect.DeepEqual(durable, want) {
|
||||
t.Fatalf("durable output payload = %#v, want %#v", durable, want)
|
||||
}
|
||||
if len(output.Warnings) != 0 {
|
||||
t.Fatalf("warnings = %#v, want grounded descriptions without warnings", output.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
type sceneDescriptionLLM struct{}
|
||||
|
||||
func (sceneDescriptionLLM) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, err
|
||||
}
|
||||
if req.PromptID != sceneextract.PromptID {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)
|
||||
}
|
||||
transcript := string(req.Inputs["transcript"].Content)
|
||||
var content string
|
||||
switch {
|
||||
case strings.Contains(transcript, "Cure Wounds"):
|
||||
content = `{"kind":"narrative","title":" Aria casts Cure Wounds ","summary":" Aria casts Cure Wounds. "}`
|
||||
case strings.Contains(transcript, "Shield"):
|
||||
content = `{"kind":"combat","title":"Bandit mage casts Shield","summary":"The bandit mage casts Shield."}`
|
||||
default:
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected transcript material %q", transcript)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), out); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate structured response: %w", err)
|
||||
}
|
||||
return contracts.StructuredCompletionResponse{Content: []byte(content), Provider: "test", Model: "deterministic", ProfileID: req.ProfileID}, nil
|
||||
}
|
||||
209
internal/cli/example_contract_test.go
Normal file
209
internal/cli/example_contract_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||
)
|
||||
|
||||
func TestMaintainedExamplesLoadResolveAndList(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
for _, example := range maintainedExampleFiles(t) {
|
||||
t.Run(example.name, func(t *testing.T) {
|
||||
cfg := loadMaintainedExample(t, example.path)
|
||||
for _, pipelineID := range example.pipelineIDs {
|
||||
effective, err := cfg.Resolve(resolveInputForMaintainedExample(components, pipelineID))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve maintained example %q: %v", pipelineID, err)
|
||||
}
|
||||
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{
|
||||
ConfigPath: example.path,
|
||||
WorkingDir: filepath.Dir(example.path),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("materialize maintained example references for %q: %v", pipelineID, err)
|
||||
}
|
||||
if example.name == "complete" {
|
||||
if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:npcs,scene-descriptions|extract-events:combat-turns,npc-interactions,spells" {
|
||||
t.Fatalf("complete example steps and lanes = %v, want every D&D extractor in the documented two-step composition", got)
|
||||
}
|
||||
spellLane := referenceContractLane(t, materialized, "spells")
|
||||
if len(spellLane.ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 ||
|
||||
len(spellLane.NormalizeReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 {
|
||||
t.Fatalf("complete example spell catalog reference was not materialized: %#v", spellLane)
|
||||
}
|
||||
}
|
||||
}
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions([]string{"pipelines", "list", "--config", example.path}, &stdout, &stderr, productionOptionsFromComponents(components))
|
||||
if code != 0 || stdout.String() != strings.Join(example.pipelineIDs, "\n")+"\n" || stderr.Len() != 0 {
|
||||
t.Fatalf("pipelines list: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintainedConfigurationExampleSet(t *testing.T) {
|
||||
entries, err := os.ReadDir(repositoryPath("examples"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".config.yml") {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
if got := strings.Join(names, ","); got != "dnd-complete.config.yml,dnd-minimal.config.yml" {
|
||||
t.Fatalf("maintained configuration examples = %q, want only the minimal and complete D&D examples", got)
|
||||
}
|
||||
}
|
||||
|
||||
func exampleStepLaneIDs(resolved pipeline.ResolvedPipeline) []string {
|
||||
result := make([]string, 0, len(resolved.Steps))
|
||||
for _, step := range resolved.Steps {
|
||||
laneIDs := make([]string, 0, len(step.ArtifactLanes))
|
||||
for _, lane := range step.ArtifactLanes {
|
||||
laneIDs = append(laneIDs, lane.ID)
|
||||
}
|
||||
sort.Strings(laneIDs)
|
||||
result = append(result, step.ID+":"+strings.Join(laneIDs, ","))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestMaintainedMinimalInvocationProducesJSONBundle(t *testing.T) {
|
||||
outputRoot := filepath.Join(t.TempDir(), "output")
|
||||
fake := &productionFakeLLMClient{}
|
||||
options := productionRunOptions(t, fake)
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions([]string{
|
||||
"run", "dnd-session",
|
||||
"--config", repositoryPath("examples", "dnd-minimal.config.yml"),
|
||||
"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
|
||||
"--only", "spells", "--chunk_cache", "bypass", "--output-dir", outputRoot,
|
||||
}, &stdout, &stderr, options)
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `pipeline "dnd-session"`) || !strings.Contains(stdout.String(), "outputs=1 rejected=0") {
|
||||
t.Fatalf("stdout=%q, want completed pipeline and counts", stdout.String())
|
||||
}
|
||||
|
||||
runRoot := filepath.Join(outputRoot, productionRunID)
|
||||
index := readProductionJSON[exampleOutputIndex](t, filepath.Join(runRoot, "index.json"))
|
||||
if index.ManifestFile != "manifest.json" || index.RejectedFile != "rejected.json" || index.WarningsFile != "warnings.json" || len(index.OutputFiles) != 1 {
|
||||
t.Fatalf("index = %#v, want one spells output and fixed companion files", index)
|
||||
}
|
||||
entry := index.OutputFiles[0]
|
||||
if entry.LaneID != "spells" || entry.File != "lanes/spells.json" || entry.MediaType != "application/json" || entry.SchemaID != "notarius.dnd.spells" || entry.SchemaVersion != "v1" {
|
||||
t.Fatalf("index output entry = %#v, want spells JSON contract", entry)
|
||||
}
|
||||
|
||||
manifest := readProductionJSON[artifacts.RunManifest](t, filepath.Join(runRoot, "manifest.json"))
|
||||
if manifest.PipelineID != "dnd-session" || manifest.InputModule != "seriatim" || manifest.Chunker != "generic" || manifest.OutputEncoder != "json" || manifest.ValidationStatus != "approved" || manifest.ChunkPlan == nil || manifest.ChunkPlan.Action != "bypassed" {
|
||||
t.Fatalf("manifest = %#v, want approved minimal run", manifest)
|
||||
}
|
||||
if len(manifest.ArtifactLanes) != 1 {
|
||||
t.Fatalf("manifest lanes = %#v, want exactly spells", manifest.ArtifactLanes)
|
||||
}
|
||||
lane := manifest.ArtifactLanes[0]
|
||||
if lane.ID != "spells" || lane.Extractor != "dnd/spells" || lane.Merger != "appendorder" || lane.Normalizer != spellnormalize.Key {
|
||||
t.Fatalf("manifest lane = %#v, want production spells composition", lane)
|
||||
}
|
||||
if len(manifest.References) != 0 {
|
||||
t.Fatalf("base-only manifest references = %#v, want no overlay provenance", manifest.References)
|
||||
}
|
||||
extractorMetadata, ok := lane.Metadata["extractor"].(map[string]any)
|
||||
if !ok || len(stringValues(extractorMetadata["catalog_overlay_ids"])) != 0 {
|
||||
t.Fatalf("base-only extractor metadata = %#v, want no overlay IDs", lane.Metadata)
|
||||
}
|
||||
|
||||
artifact := readProductionJSON[dnd.SpellList](t, filepath.Join(runRoot, entry.File))
|
||||
if len(artifact.SpellCasts) != 1 || artifact.SpellCasts[0].Spell != "Cure Wounds" || artifact.SpellCasts[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||
t.Fatalf("artifact = %#v, want one source-linked Cure Wounds cast", artifact)
|
||||
}
|
||||
rejected := readProductionJSON[struct {
|
||||
Rejected []json.RawMessage `json:"rejected"`
|
||||
}](t, filepath.Join(runRoot, "rejected.json"))
|
||||
if len(rejected.Rejected) != 0 {
|
||||
t.Fatalf("rejected = %#v, want empty rejection list", rejected.Rejected)
|
||||
}
|
||||
warnings := readProductionJSON[struct {
|
||||
Warnings []json.RawMessage `json:"warnings"`
|
||||
}](t, filepath.Join(runRoot, "warnings.json"))
|
||||
if len(warnings.Warnings) != 0 {
|
||||
t.Fatalf("warnings = %#v, want empty warning list", warnings.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintainedMalformedInputOnlyRecordsDebugFailureWhenRequested(t *testing.T) {
|
||||
malformed := filepath.Join(t.TempDir(), "malformed.json")
|
||||
if err := os.WriteFile(malformed, []byte("{not valid json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, debug := range []bool{false, true} {
|
||||
name := "without debug"
|
||||
if debug {
|
||||
name = "with debug"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
outputRoot := filepath.Join(t.TempDir(), "output")
|
||||
debugRoot := filepath.Join(t.TempDir(), "debug")
|
||||
options := productionRunOptions(t, &productionFakeLLMClient{})
|
||||
args := []string{
|
||||
"run", "dnd-session",
|
||||
"--config", repositoryPath("examples", "dnd-minimal.config.yml"),
|
||||
"--input", malformed, "--chunk_cache", "bypass", "--output-dir", outputRoot,
|
||||
}
|
||||
if debug {
|
||||
args = append(args, "--debug", "--debug-dir", debugRoot)
|
||||
}
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions(args, &stdout, &stderr, options)
|
||||
if code != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "parse input") {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertAbsent(t, outputRoot)
|
||||
if !debug {
|
||||
assertAbsent(t, debugRoot)
|
||||
return
|
||||
}
|
||||
bundle := onlyChildDir(t, debugRoot)
|
||||
report := readProductionJSON[debugbundle.RunReport](t, filepath.Join(bundle, "summary", "run-report.json"))
|
||||
if report.Succeeded || report.PipelineID != "dnd-session" {
|
||||
t.Fatalf("failure report = %#v, want failed dnd-session report", report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type exampleOutputIndex struct {
|
||||
ManifestFile string `json:"manifest_file"`
|
||||
OutputFiles []exampleOutputIndexEntry `json:"output_files"`
|
||||
RejectedFile string `json:"rejected_file"`
|
||||
WarningsFile string `json:"warnings_file"`
|
||||
}
|
||||
|
||||
type exampleOutputIndexEntry struct {
|
||||
LaneID string `json:"lane_id"`
|
||||
MediaType string `json:"media_type"`
|
||||
File string `json:"file"`
|
||||
SchemaID string `json:"schema_id"`
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
}
|
||||
|
||||
func resolveInputForMaintainedExample(components productionComponents, pipelineID string) config.ResolveInput {
|
||||
return config.ResolveInput{PipelineID: pipelineID, Catalog: catalogFromRegistries(components.registries)}
|
||||
}
|
||||
80
internal/cli/npc_registry_contract_test.go
Normal file
80
internal/cli/npc_registry_contract_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestOversizedNPCRegistryFailsBeforeRuntimeAndCheckpointConstruction(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
npcPath := filepath.Join(t.TempDir(), "npcs.json")
|
||||
if err := os.WriteFile(npcPath, []byte(strings.Repeat("x", 1048577)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkpointRoot := filepath.Join(t.TempDir(), "checkpoints")
|
||||
content := fmt.Sprintf(`version: 3
|
||||
cache:
|
||||
chunk_plans:
|
||||
mode: bypass
|
||||
checkpoints:
|
||||
enabled: true
|
||||
directory: %q
|
||||
pipelines:
|
||||
dnd-session:
|
||||
input: seriatim
|
||||
artifacts:
|
||||
spells:
|
||||
extract:
|
||||
module: dnd/spells
|
||||
references:
|
||||
npcs: %q
|
||||
normalize: dnd/spells
|
||||
`, checkpointRoot, npcPath)
|
||||
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
llmConstructed := false
|
||||
chunkStoreConstructed := false
|
||||
options := Options{
|
||||
Catalog: catalogFromRegistries(components.registries),
|
||||
Registries: components.registries,
|
||||
LLMClientFactory: func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
llmConstructed = true
|
||||
return nil, nil, errors.New("LLM client must not be constructed")
|
||||
},
|
||||
ChunkPlanStoreFactory: func(string) (pipeline.ChunkPlanStore, error) {
|
||||
chunkStoreConstructed = true
|
||||
return nil, errors.New("chunk-plan store must not be constructed")
|
||||
},
|
||||
}
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions([]string{
|
||||
"run", "dnd-session", "--config", configPath,
|
||||
"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
|
||||
"--chunk_cache", "bypass", "--output-dir", t.TempDir(),
|
||||
}, &stdout, &stderr, options)
|
||||
for _, fragment := range []string{`pipeline "dnd-session"`, `reference slot "npcs"`, "1048577 bytes", "limit 1048576"} {
|
||||
if code == 0 || !strings.Contains(stderr.String(), fragment) {
|
||||
t.Fatalf("RunWithOptions() code = %d stderr = %q, want context fragment %q", code, stderr.String(), fragment)
|
||||
}
|
||||
}
|
||||
if llmConstructed || chunkStoreConstructed {
|
||||
t.Fatalf("runtime construction = LLM %t, chunk store %t; want materialization failure first", llmConstructed, chunkStoreConstructed)
|
||||
}
|
||||
if _, err := os.Stat(checkpointRoot); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("checkpoint root stat error = %v, want no checkpoint allocation", err)
|
||||
}
|
||||
}
|
||||
805
internal/cli/production_contract_test.go
Normal file
805
internal/cli/production_contract_test.go
Normal file
@@ -0,0 +1,805 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"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"
|
||||
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
|
||||
)
|
||||
|
||||
func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
registries := components.registries
|
||||
|
||||
assertProductionContains(t, "inputs", registries.Inputs.RegisteredKeys(), []string{"seriatim"})
|
||||
assertProductionContains(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes", "generic"})
|
||||
assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", "dnd/npcs", combatextract.Key})
|
||||
assertProductionContains(t, "mergers", registries.Mergers.RegisteredKeys(), []string{"appendorder"})
|
||||
assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key, "dnd/npcs", combatnormalize.Key})
|
||||
assertProductionContains(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"})
|
||||
assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{
|
||||
"extract/dnd/spells/catalog",
|
||||
"extract/dnd/spells/shape",
|
||||
"extract/dnd/spells/source_refs",
|
||||
"extract/dnd/spells/source_relatedness",
|
||||
"extract/dnd/combat-turns/shape",
|
||||
"extract/dnd/combat-turns/source_refs",
|
||||
"extract/dnd/combat-turns/source_relatedness",
|
||||
"normalize/dnd/combat-turns/invariants",
|
||||
"generic/always_accept",
|
||||
"generic/always_reject",
|
||||
"generic/valid_json",
|
||||
"generic/valid_json_schema",
|
||||
})
|
||||
assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind})
|
||||
assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind})
|
||||
assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind})
|
||||
assertProductionContains(t, "spell normalizer variants", registries.Normalizers.RegisteredArtifactKinds(spellnormalize.Key), []contracts.ArtifactKind{dnd.SpellListKind})
|
||||
assertProductionContains(t, "combat normalizer variants", registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind})
|
||||
|
||||
wantChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("extract/dnd/spells/shape"),
|
||||
pipeline.Binding("extract/dnd/spells/catalog"),
|
||||
pipeline.Binding("extract/dnd/spells/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/spells/source_relatedness"),
|
||||
}
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
|
||||
t.Fatalf("spell validator chain = %#v, want %#v", got, wantChain)
|
||||
}
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, spellnormalize.Key); !reflect.DeepEqual(got, wantChain) {
|
||||
t.Fatalf("spell normalize validator chain = %#v, want %#v", got, wantChain)
|
||||
}
|
||||
combatExtractChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||
}
|
||||
combatNormalizeChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/shape"),
|
||||
pipeline.Binding("normalize/dnd/combat-turns/invariants"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
|
||||
}
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, combatExtractChain) {
|
||||
t.Fatalf("combat extract validator chain = %#v, want %#v", got, combatExtractChain)
|
||||
}
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) {
|
||||
t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain)
|
||||
}
|
||||
|
||||
assetNames := productionAssetNames(t, components.assets.PromptFS)
|
||||
requiredAssets := []string{
|
||||
"dnd.scenes/dnd.scenes.yaml",
|
||||
"dnd.scenes/instructions.md",
|
||||
"dnd.scenes/sharedassets/common-dnd-references.md",
|
||||
"dnd.scenes/sharedassets/common-dnd-system.md",
|
||||
"dnd.scenes/sharedassets/common-dnd-transcript.md",
|
||||
"dnd.scenes/task.md",
|
||||
"dnd.spells/dnd.spells.yaml",
|
||||
"dnd.spells/catalog.md",
|
||||
"dnd.spells/instructions.md",
|
||||
"dnd.spells/sharedassets/common-dnd-references.md",
|
||||
"dnd.spells/sharedassets/common-dnd-system.md",
|
||||
"dnd.spells/sharedassets/common-dnd-transcript.md",
|
||||
"dnd.spells/task.md",
|
||||
"dnd.combat_turns/dnd.combat_turns.yaml",
|
||||
"dnd.combat_turns/instructions.md",
|
||||
"dnd.combat_turns/sharedassets/common-dnd-references.md",
|
||||
"dnd.combat_turns/sharedassets/common-dnd-system.md",
|
||||
"dnd.combat_turns/sharedassets/common-dnd-transcript.md",
|
||||
"dnd.combat_turns/task.md",
|
||||
}
|
||||
assertProductionContains(t, "production prompt assets", assetNames, requiredAssets)
|
||||
|
||||
catalog := catalogFromRegistries(registries)
|
||||
converted := registriesFromCatalog(catalog)
|
||||
if converted.ArtifactCodecs != registries.ArtifactCodecs || converted.ValidatorChains != registries.ValidatorChains {
|
||||
t.Fatal("catalog/registry conversion did not preserve codec and validator-chain registries")
|
||||
}
|
||||
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.SpellListKind)
|
||||
if !ok || codecSpec.Kind != dnd.SpellListKind || codecSpec.Schema.ID != spellcodec.SchemaID {
|
||||
t.Fatalf("catalog codec spec = %#v, ok=%t, want typed D&D spell codec", codecSpec, ok)
|
||||
}
|
||||
combatCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.CombatTurnListKind)
|
||||
if !ok || combatCodecSpec.Kind != dnd.CombatTurnListKind || combatCodecSpec.Schema.ID != combatcodec.SchemaID {
|
||||
t.Fatalf("combat codec spec = %#v, ok=%t, want typed D&D combat codec", combatCodecSpec, ok)
|
||||
}
|
||||
if got := catalog.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
|
||||
t.Fatalf("catalog validator chain = %#v, want %#v", got, wantChain)
|
||||
}
|
||||
if got := catalog.ValidatorChains.Validators(pipeline.StageNormalize, spellnormalize.Key); !reflect.DeepEqual(got, wantChain) {
|
||||
t.Fatalf("catalog spell normalize validator chain = %#v, want %#v", got, wantChain)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestDefaultCLICompositionValidatesRepresentativeConfiguration(t *testing.T) {
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions([]string{
|
||||
"config", "validate", "--config", repositoryPath("examples", "dnd-minimal.config.yml"), "--pipeline", "dnd-session",
|
||||
}, &stdout, &stderr, Options{})
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("validate representative config with default composition: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionPromptAssetsPrepareWithoutProviderCredentials(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
cfg := config.Default()
|
||||
cfg.Pipelines["dnd-scenes"] = pipeline.PipelineProfile{
|
||||
ID: "dnd-scenes",
|
||||
Input: pipeline.Binding("seriatim"),
|
||||
Chunk: pipeline.Binding("dnd/scenes"),
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"spells": {Extract: pipeline.Binding("dnd/spells")},
|
||||
},
|
||||
}
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-scenes", Catalog: catalogFromRegistries(components.registries)})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve production scene pipeline: %v", err)
|
||||
}
|
||||
if _, err := pipeline.Prepare(effective.ResolvedPipeline, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil {
|
||||
t.Fatalf("prepare production scene and spell modules: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionSpellValidatorsPrepareFromMaterializedCatalog(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
configPath := writeProductionSpellCatalogContractConfig(t)
|
||||
effective, err := loadMaintainedExample(t, configPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve production spell configuration: %v", err)
|
||||
}
|
||||
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{
|
||||
ConfigPath: configPath,
|
||||
WorkingDir: filepath.Dir(configPath),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("materialize production spell references: %v", err)
|
||||
}
|
||||
extractItems := materialized.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items
|
||||
normalizeItems := materialized.Steps[0].ArtifactLanes[0].NormalizeReferences.ReferenceSet.Slots["spell_catalog"].Items
|
||||
if len(extractItems) != 1 || extractItems[0].MediaType != "application/json" || len(extractItems[0].Content) == 0 {
|
||||
t.Fatalf("materialized extract spell catalog items = %#v, want one JSON item", extractItems)
|
||||
}
|
||||
if len(normalizeItems) != 1 || normalizeItems[0].MediaType != "application/json" || !reflect.DeepEqual(normalizeItems[0].Content, extractItems[0].Content) {
|
||||
t.Fatalf("materialized normalize spell catalog items = %#v, want an independent binding of the extract catalog", normalizeItems)
|
||||
}
|
||||
if _, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil {
|
||||
t.Fatalf("prepare production spell pipeline from materialized catalog: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionSpellNormalizerRejectsInvalidCatalogReferencesBeforeExecution(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
configPath := writeProductionSpellCatalogContractConfig(t)
|
||||
resolve := func(t *testing.T) pipeline.ResolvedPipeline {
|
||||
t.Helper()
|
||||
effective, err := loadMaintainedExample(t, configPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve production spell configuration: %v", err)
|
||||
}
|
||||
return effective.ResolvedPipeline
|
||||
}
|
||||
materialize := func(resolved pipeline.ResolvedPipeline) (pipeline.ResolvedPipeline, error) {
|
||||
materialized, _, err := pipeline.MaterializeReferences(resolved, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{
|
||||
ConfigPath: configPath,
|
||||
WorkingDir: filepath.Dir(configPath),
|
||||
})
|
||||
return materialized, err
|
||||
}
|
||||
|
||||
t.Run("malformed catalog fails preparation", func(t *testing.T) {
|
||||
catalogPath := filepath.Join(t.TempDir(), "malformed.json")
|
||||
if err := os.WriteFile(catalogPath, []byte(`{"schema_version":`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved := resolve(t)
|
||||
setNormalizeSpellCatalogSource(t, &resolved, catalogPath)
|
||||
materialized, err := materialize(resolved)
|
||||
if err != nil {
|
||||
t.Fatalf("MaterializeReferences() error = %v, want malformed JSON to reach preparation", err)
|
||||
}
|
||||
_, err = pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}})
|
||||
for _, fragment := range []string{`pipeline "dnd-session"`, `lane "spells"`, "normalize", `module "dnd/spells"`, "decode spell catalog overlay"} {
|
||||
if err == nil || !strings.Contains(err.Error(), fragment) {
|
||||
t.Fatalf("Prepare() error = %v, want context fragment %q", err, fragment)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple catalog items fail preparation", func(t *testing.T) {
|
||||
materialized, err := materialize(resolve(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slot := materialized.Steps[0].ArtifactLanes[0].NormalizeReferences.ReferenceSet.Slots["spell_catalog"]
|
||||
slot.Items = append(slot.Items, slot.Items[0])
|
||||
materialized.Steps[0].ArtifactLanes[0].NormalizeReferences.ReferenceSet.Slots["spell_catalog"] = slot
|
||||
_, err = pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}})
|
||||
for _, fragment := range []string{"normalize", `module "dnd/spells"`, "zero or one item"} {
|
||||
if err == nil || !strings.Contains(err.Error(), fragment) {
|
||||
t.Fatalf("Prepare() error = %v, want context fragment %q", err, fragment)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized catalog fails materialization", func(t *testing.T) {
|
||||
catalogPath := filepath.Join(t.TempDir(), "oversized.json")
|
||||
if err := os.WriteFile(catalogPath, []byte(strings.Repeat("x", 1048577)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkpointRoot := filepath.Join(t.TempDir(), "checkpoints")
|
||||
content := productionSpellCatalogContractConfig(t)
|
||||
catalogSource := repositoryPath("examples", "dnd-spell-catalog.json")
|
||||
if count := strings.Count(content, catalogSource); count != 2 {
|
||||
t.Fatalf("spell catalog source occurs %d times, want extract and normalize bindings", count)
|
||||
}
|
||||
content = strings.Replace(content, catalogSource, "__extract_catalog__", 1)
|
||||
content = replaceRequiredOnce(t, content, catalogSource, catalogPath)
|
||||
content = replaceRequiredOnce(t, content, "__extract_catalog__", catalogSource)
|
||||
content = replaceRequiredOnce(t, content, " enabled: false\n directory: \"\"", " enabled: true\n directory: "+checkpointRoot)
|
||||
configFile := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(configFile, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
llmConstructed := false
|
||||
chunkStoreConstructed := false
|
||||
options := Options{
|
||||
Catalog: catalogFromRegistries(components.registries),
|
||||
Registries: components.registries,
|
||||
LLMClientFactory: func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
llmConstructed = true
|
||||
return nil, nil, errors.New("LLM client must not be constructed")
|
||||
},
|
||||
ChunkPlanStoreFactory: func(string) (pipeline.ChunkPlanStore, error) {
|
||||
chunkStoreConstructed = true
|
||||
return nil, errors.New("chunk-plan store must not be constructed")
|
||||
},
|
||||
}
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions([]string{
|
||||
"run", "dnd-session", "--config", configFile,
|
||||
"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
|
||||
}, &stdout, &stderr, options)
|
||||
errText := stderr.String()
|
||||
for _, fragment := range []string{"normalize", `lane "spells"`, `reference slot "spell_catalog"`, "1048577 bytes", "limit 1048576"} {
|
||||
if code == 0 || !strings.Contains(errText, fragment) {
|
||||
t.Fatalf("RunWithOptions() code = %d stderr = %q, want context fragment %q", code, errText, fragment)
|
||||
}
|
||||
}
|
||||
if llmConstructed || chunkStoreConstructed {
|
||||
t.Fatalf("runtime construction = LLM %t, chunk store %t; want materialization failure first", llmConstructed, chunkStoreConstructed)
|
||||
}
|
||||
if _, err := os.Stat(checkpointRoot); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("checkpoint root stat error = %v, want no checkpoint allocation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setNormalizeSpellCatalogSource(t *testing.T, resolved *pipeline.ResolvedPipeline, sourcePath string) {
|
||||
t.Helper()
|
||||
if resolved == nil || len(resolved.Steps[0].ArtifactLanes) != 1 {
|
||||
t.Fatalf("resolved pipeline = %#v, want one artifact lane", resolved)
|
||||
}
|
||||
bindings := resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings
|
||||
matches := 0
|
||||
for index := range bindings {
|
||||
if bindings[index].SlotName == "spell_catalog" {
|
||||
bindings[index].Source = sourcePath
|
||||
matches++
|
||||
}
|
||||
}
|
||||
if matches != 1 {
|
||||
t.Fatalf("normalize reference bindings = %#v, want exactly one spell_catalog binding", bindings)
|
||||
}
|
||||
resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings = bindings
|
||||
}
|
||||
|
||||
func TestProductionLLMClientFactoriesBuildOfflineRuntime(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
factories := []struct {
|
||||
name string
|
||||
factory LLMClientFactory
|
||||
}{
|
||||
{name: "default production assets", factory: productionLLMClientFactory},
|
||||
{name: "provided production assets", factory: productionLLMClientFactoryWithAssets(components.assets)},
|
||||
}
|
||||
for _, tt := range factories {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client, manifests, err := tt.factory(context.Background(), config.Default(), "test-profile")
|
||||
if err != nil {
|
||||
t.Fatalf("build production LLM runtime: %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("production LLM runtime returned a nil client")
|
||||
}
|
||||
if len(manifests) != 0 {
|
||||
t.Fatalf("eager profile manifests = %#v, want none", manifests)
|
||||
}
|
||||
if _, ok := client.(contracts.LLMProfileManifestProvider); !ok {
|
||||
t.Fatalf("production LLM client %T does not provide profile manifests", client)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionLLMClientFactoriesRejectInvalidConstruction(t *testing.T) {
|
||||
t.Run("canceled context", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
client, manifests, err := productionLLMClientFactory(ctx, config.Default(), "test-profile")
|
||||
if !errors.Is(err, context.Canceled) || client != nil || len(manifests) != 0 {
|
||||
t.Fatalf("client=%T manifests=%#v error=%v, want canceled construction", client, manifests, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil assets", func(t *testing.T) {
|
||||
client, manifests, err := productionLLMClientFactoryWithAssets(nil)(context.Background(), config.Default(), "test-profile")
|
||||
if err == nil || !strings.Contains(err.Error(), "asset registry must not be nil") || client != nil || len(manifests) != 0 {
|
||||
t.Fatalf("client=%T manifests=%#v error=%v, want nil-assets failure", client, manifests, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid scheduler concurrency", func(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
cfg := config.Default()
|
||||
cfg.Concurrency.TotalLLM = 0
|
||||
client, manifests, err := productionLLMClientFactoryWithAssets(components.assets)(context.Background(), cfg, "test-profile")
|
||||
if err == nil || !strings.Contains(err.Error(), "create LLM scheduler") || !strings.Contains(err.Error(), "greater than zero") || client != nil || len(manifests) != 0 {
|
||||
t.Fatalf("client=%T manifests=%#v error=%v, want scheduler-construction failure", client, manifests, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProductionConfigValidationCoversModuleAndVariantFailures(t *testing.T) {
|
||||
base := string(readRepositoryFile(t, "examples", "dnd-minimal.config.yml"))
|
||||
validPath := writeProductionContractConfig(t, base)
|
||||
options := productionCLIOptions(t)
|
||||
var stdout, stderr strings.Builder
|
||||
if code := RunWithOptions([]string{"config", "validate", "--config", validPath, "--pipeline", "dnd-session"}, &stdout, &stderr, options); code != 0 {
|
||||
t.Fatalf("valid production config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
options Options
|
||||
fragments []string
|
||||
}{
|
||||
{
|
||||
name: "unknown module",
|
||||
content: replaceRequiredOnce(t, base, " input: seriatim\n", " input: missing/input\n"),
|
||||
options: productionCLIOptions(t),
|
||||
fragments: []string{"pipeline \"dnd-session\"", "input", "missing/input"},
|
||||
},
|
||||
{
|
||||
name: "unknown validator",
|
||||
content: replaceRequiredOnce(t, base, " extract: dnd/spells\n", " extract:\n module: dnd/spells\n validators:\n - module: missing/validator\n"),
|
||||
options: productionCLIOptions(t),
|
||||
fragments: []string{"validator", "missing/validator"},
|
||||
},
|
||||
{
|
||||
name: "invalid artifact variant",
|
||||
content: base,
|
||||
options: productionCLIOptionsWithoutSpellNormalizer(t),
|
||||
fragments: []string{"normalizer", spellnormalize.Key, string(dnd.SpellListKind), "variant"},
|
||||
},
|
||||
{
|
||||
name: "deterministic validator with profile",
|
||||
content: replaceRequiredOnce(t, base, " extract: dnd/spells\n", " extract:\n module: dnd/spells\n validators:\n - module: generic/valid_json\n llm_profile: forbidden-profile\n"),
|
||||
options: productionCLIOptions(t),
|
||||
fragments: []string{"deterministic validator", "llm_profile"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := writeProductionContractConfig(t, tt.content)
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", path, "--pipeline", "dnd-session"}, &stdout, &stderr, tt.options)
|
||||
if code != 1 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
for _, fragment := range tt.fragments {
|
||||
if !strings.Contains(stderr.String(), fragment) {
|
||||
t.Fatalf("stderr=%q, want %q", stderr.String(), fragment)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionNormalizeValidatorOverrideRemainsAuthoritative(t *testing.T) {
|
||||
base := string(readRepositoryFile(t, "examples", "dnd-minimal.config.yml"))
|
||||
content := replaceRequiredOnce(t, base, " normalize: dnd/spells\n", " normalize:\n module: dnd/spells\n validators:\n - module: generic/always_accept\n - module: generic/valid_json\n")
|
||||
path := writeProductionContractConfig(t, content)
|
||||
components := productionTestComponents(t)
|
||||
effective, err := loadMaintainedExample(t, path).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve normalize override: %v", err)
|
||||
}
|
||||
for _, chain := range effective.ResolvedPipeline.ValidatorChains {
|
||||
if chain.Stage != pipeline.StageNormalize || chain.ModuleKey != spellnormalize.Key {
|
||||
continue
|
||||
}
|
||||
if len(chain.Validators) != 2 || chain.Validators[0].Binding.Module != "generic/always_accept" || chain.Validators[1].Binding.Module != "generic/valid_json" {
|
||||
t.Fatalf("normalize validator chain = %#v, want explicit validator order", chain)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("resolved validator chains = %#v, want normalize chain for %q", effective.ResolvedPipeline.ValidatorChains, spellnormalize.Key)
|
||||
}
|
||||
|
||||
func TestProductionSceneRunRecordsAnnotationFreeChunkPlanAndProvenance(t *testing.T) {
|
||||
outputRoot := filepath.Join(t.TempDir(), "output")
|
||||
configPath := writeProductionContractConfig(t, productionRunConfig(outputRoot, "dnd/scenes"))
|
||||
fake := &productionFakeLLMClient{}
|
||||
options := productionRunOptions(t, fake)
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions([]string{
|
||||
"run", "dnd-session", "--config", configPath,
|
||||
"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
|
||||
"--chunk_cache", "bypass", "--session-id", "offline-session",
|
||||
}, &stdout, &stderr, options)
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
manifest := readProductionJSON[artifacts.RunManifest](t, filepath.Join(outputRoot, productionRunID, "manifest.json"))
|
||||
if manifest.Chunker != scenes.Key || manifest.ChunkPlan == nil || manifest.ChunkPlan.Action != "bypassed" || manifest.ChunkPlan.ProducerModule != scenes.Key {
|
||||
t.Fatalf("chunk manifest = %#v, want dnd scene producer", manifest.ChunkPlan)
|
||||
}
|
||||
if got := manifest.ModuleMetadata["chunker"]["prompt_id"]; got != scenes.PromptID {
|
||||
t.Fatalf("chunker prompt metadata = %#v, want %q", got, scenes.PromptID)
|
||||
}
|
||||
if got := manifest.ChunkPlan.ProducerMetadata["response_schema_id"]; got != scenes.ResponseSchemaID {
|
||||
t.Fatalf("chunk producer schema metadata = %#v, want %q", got, scenes.ResponseSchemaID)
|
||||
}
|
||||
index := readProductionJSON[productionChunkMapIndex](t, filepath.Join(outputRoot, productionRunID, "index.json"))
|
||||
if index.ChunkMap == nil || index.ChunkMap.ArtifactKind != chunkmap.ArtifactKind || index.ChunkMap.File != "chunk-map.json" || index.ChunkMap.MediaType != chunkmap.MediaType || index.ChunkMap.SchemaID != chunkmap.SchemaID || index.ChunkMap.SchemaName != chunkmap.SchemaName || index.ChunkMap.SchemaVersion != chunkmap.SchemaVersion {
|
||||
t.Fatalf("chunk map index = %#v, want fixed chunk map descriptor", index.ChunkMap)
|
||||
}
|
||||
for _, output := range index.OutputFiles {
|
||||
if output.File == index.ChunkMap.File {
|
||||
t.Fatalf("lane output files = %#v, want no chunk map", index.OutputFiles)
|
||||
}
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(outputRoot, productionRunID, index.ChunkMap.File))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chunkMap, err := chunkmap.New().Decode(content)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(chunk map) error = %v", err)
|
||||
}
|
||||
if chunkMap.SourceID != "session-alpha" || chunkMap.SourceDigest != manifest.ChunkPlan.SourceDigest || chunkMap.PlanDigest != manifest.ChunkPlan.PlanDigest || chunkMap.RequestedChunker != scenes.Key || chunkMap.Producer.InputModule != "seriatim" || chunkMap.Producer.ChunkModule != scenes.Key || chunkMap.Producer.LLMProfile != manifest.ChunkPlan.ProducerLLMProfile {
|
||||
t.Fatalf("chunk map identity and producer = %#v, want accepted scene plan provenance", chunkMap)
|
||||
}
|
||||
if len(chunkMap.Chunks) != 1 || chunkMap.Chunks[0].ID != "chunk-000001" || chunkMap.Chunks[0].Index != 0 || chunkMap.Chunks[0].SourceRef.SourceID != "session-alpha" || chunkMap.Chunks[0].SourceRef.StartUnitID != 1 || chunkMap.Chunks[0].SourceRef.EndUnitID != 2 || chunkMap.Chunks[0].UnitCount != 2 {
|
||||
t.Fatalf("chunk map chunks = %#v, want one stable accepted scene range", chunkMap.Chunks)
|
||||
}
|
||||
if len(chunkMap.PlanAnnotations) != 0 {
|
||||
t.Fatalf("chunk map plan annotations = %#v, want none", chunkMap.PlanAnnotations)
|
||||
}
|
||||
if len(chunkMap.Chunks[0].Annotations) != 0 {
|
||||
t.Fatalf("chunk map range annotations = %#v, want none", chunkMap.Chunks[0].Annotations)
|
||||
}
|
||||
warnings := readProductionJSON[struct {
|
||||
Warnings []contracts.Warning `json:"warnings"`
|
||||
}](t, filepath.Join(outputRoot, productionRunID, "warnings.json"))
|
||||
if len(warnings.Warnings) != 0 {
|
||||
t.Fatalf("warnings = %#v, want none", warnings.Warnings)
|
||||
}
|
||||
if len(fake.requestsFor(scenes.PromptID)) != 1 || len(fake.requestsFor(spells.PromptID)) != 1 {
|
||||
t.Fatalf("fake prompt requests = %#v, want one scene and one spell request", fake.requestPrompts())
|
||||
}
|
||||
}
|
||||
|
||||
type maintainedExample struct {
|
||||
name string
|
||||
path string
|
||||
pipelineIDs []string
|
||||
}
|
||||
|
||||
func maintainedExampleFiles(t *testing.T) []maintainedExample {
|
||||
t.Helper()
|
||||
return []maintainedExample{
|
||||
{name: "minimal", path: repositoryPath("examples", "dnd-minimal.config.yml"), pipelineIDs: []string{"dnd-session"}},
|
||||
{name: "complete", path: repositoryPath("examples", "dnd-complete.config.yml"), pipelineIDs: []string{"dnd-session"}},
|
||||
}
|
||||
}
|
||||
|
||||
func productionSpellCatalogContractConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
return fmt.Sprintf(`version: 3
|
||||
cache:
|
||||
chunk_plans:
|
||||
mode: bypass
|
||||
checkpoints:
|
||||
enabled: false
|
||||
directory: ""
|
||||
pipelines:
|
||||
dnd-session:
|
||||
input: seriatim
|
||||
references:
|
||||
party: %q
|
||||
glossary: %q
|
||||
artifacts:
|
||||
spells:
|
||||
extract:
|
||||
module: dnd/spells
|
||||
retries: 2
|
||||
references:
|
||||
spell_catalog: %q
|
||||
normalize:
|
||||
module: dnd/spells
|
||||
references:
|
||||
spell_catalog: %q
|
||||
`, repositoryPath("examples", "dnd-party.txt"), repositoryPath("examples", "dnd-glossary.txt"), repositoryPath("examples", "dnd-spell-catalog.json"), repositoryPath("examples", "dnd-spell-catalog.json"))
|
||||
}
|
||||
|
||||
func writeProductionSpellCatalogContractConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
return writeProductionContractConfig(t, productionSpellCatalogContractConfig(t))
|
||||
}
|
||||
|
||||
func loadMaintainedExample(t *testing.T, path string) config.Config {
|
||||
t.Helper()
|
||||
fileConfig, err := config.LoadFileConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load maintained config %q: %v", path, err)
|
||||
}
|
||||
cfg := config.Default()
|
||||
if err := cfg.ApplyFileConfig(fileConfig); err != nil {
|
||||
t.Fatalf("apply maintained config %q: %v", path, err)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("validate maintained config %q: %v", path, err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func productionTestComponents(t *testing.T) productionComponents {
|
||||
t.Helper()
|
||||
components, err := newProductionComponents()
|
||||
if err != nil {
|
||||
t.Fatalf("new production components: %v", err)
|
||||
}
|
||||
return components
|
||||
}
|
||||
|
||||
func productionCLIOptions(t *testing.T) Options {
|
||||
t.Helper()
|
||||
components := productionTestComponents(t)
|
||||
return productionOptionsFromComponents(components)
|
||||
}
|
||||
|
||||
func productionOptionsFromComponents(components productionComponents) Options {
|
||||
return Options{
|
||||
Catalog: catalogFromRegistries(components.registries),
|
||||
Registries: components.registries,
|
||||
LookupEnv: emptyLookup,
|
||||
}
|
||||
}
|
||||
|
||||
func productionCLIOptionsWithoutSpellNormalizer(t *testing.T) Options {
|
||||
t.Helper()
|
||||
components := productionTestComponents(t)
|
||||
registries := components.registries
|
||||
registries.Normalizers = pipeline.NewNormalizerRegistry()
|
||||
if err := noop.RegisterTyped[dnd.SpellList](registries.Normalizers, contracts.ArtifactKind("test/other")); err != nil {
|
||||
t.Fatalf("register mismatched normalizer: %v", err)
|
||||
}
|
||||
return productionOptionsFromComponents(productionComponents{registries: registries, assets: components.assets})
|
||||
}
|
||||
|
||||
const productionRunID = "run-1700000000000000000-0123456789abcdef0123456789abcdef"
|
||||
|
||||
func productionRunOptions(t *testing.T, fake *productionFakeLLMClient) Options {
|
||||
t.Helper()
|
||||
options := productionCLIOptions(t)
|
||||
options.Now = func() time.Time { return time.Unix(1700000000, 0).UTC() }
|
||||
options.RunIDGenerator = func(time.Time) (string, error) { return productionRunID, nil }
|
||||
options.UserCacheDir = func() (string, error) { return "", errors.New("user cache must not be used") }
|
||||
options.LLMClientFactory = func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
return fake, nil, nil
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func productionRunConfig(outputRoot, chunkModule string) string {
|
||||
return fmt.Sprintf(`version: 3
|
||||
output:
|
||||
directory: %q
|
||||
cache:
|
||||
chunk_plans:
|
||||
mode: bypass
|
||||
checkpoints: {}
|
||||
debug:
|
||||
directory: %q
|
||||
pipelines:
|
||||
dnd-session:
|
||||
input: seriatim
|
||||
chunk: %s
|
||||
output:
|
||||
module: json
|
||||
options:
|
||||
include_chunk_map: true
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
`, outputRoot, filepath.Join(filepath.Dir(outputRoot), "debug"), chunkModule)
|
||||
}
|
||||
|
||||
type productionChunkMapIndex struct {
|
||||
OutputFiles []struct {
|
||||
File string `json:"file"`
|
||||
} `json:"output_files"`
|
||||
ChunkMap *struct {
|
||||
ArtifactKind contracts.ArtifactKind `json:"artifact_kind"`
|
||||
File string `json:"file"`
|
||||
MediaType string `json:"media_type"`
|
||||
SchemaID string `json:"schema_id"`
|
||||
SchemaName string `json:"schema_name"`
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
} `json:"chunk_map"`
|
||||
}
|
||||
|
||||
func writeProductionContractConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func productionAssetNames(t *testing.T, getFS func() (fs.FS, error)) []string {
|
||||
t.Helper()
|
||||
fileSystem, err := getFS()
|
||||
if err != nil {
|
||||
t.Fatalf("load production prompt assets: %v", err)
|
||||
}
|
||||
var names []string
|
||||
if err := fs.WalkDir(fileSystem, ".", func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !entry.IsDir() {
|
||||
names = append(names, path)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("walk production prompt assets: %v", err)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func assertProductionContains[T comparable](t *testing.T, name string, got, required []T) {
|
||||
t.Helper()
|
||||
available := make(map[T]struct{}, len(got))
|
||||
for _, entry := range got {
|
||||
available[entry] = struct{}{}
|
||||
}
|
||||
var missing []T
|
||||
for _, entry := range required {
|
||||
if _, ok := available[entry]; !ok {
|
||||
missing = append(missing, entry)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("%s missing required entries %#v; registered entries are %#v", name, missing, got)
|
||||
}
|
||||
}
|
||||
|
||||
func readProductionJSON[T any](t *testing.T, path string) T {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var value T
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type productionFakeLLMClient struct {
|
||||
mu sync.Mutex
|
||||
requests []contracts.StructuredCompletionRequest
|
||||
spellResponse string
|
||||
}
|
||||
|
||||
func (client *productionFakeLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, err
|
||||
}
|
||||
var content []byte
|
||||
switch req.PromptID {
|
||||
case scenes.PromptID:
|
||||
content = []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":2}]}`)
|
||||
case spells.PromptID:
|
||||
if client.spellResponse != "" {
|
||||
content = []byte(client.spellResponse)
|
||||
} else {
|
||||
content = []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)
|
||||
}
|
||||
default:
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)
|
||||
}
|
||||
if err := json.Unmarshal(content, out); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate fake structured target: %w", err)
|
||||
}
|
||||
client.mu.Lock()
|
||||
client.requests = append(client.requests, req)
|
||||
client.mu.Unlock()
|
||||
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "deterministic", ProfileID: req.ProfileID}, nil
|
||||
}
|
||||
|
||||
func (client *productionFakeLLMClient) requestsFor(promptID string) []contracts.StructuredCompletionRequest {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
var requests []contracts.StructuredCompletionRequest
|
||||
for _, req := range client.requests {
|
||||
if req.PromptID == promptID {
|
||||
requests = append(requests, req)
|
||||
}
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
func (client *productionFakeLLMClient) requestPrompts() []string {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
prompts := make([]string, 0, len(client.requests))
|
||||
for _, req := range client.requests {
|
||||
prompts = append(prompts, req.PromptID)
|
||||
}
|
||||
return prompts
|
||||
}
|
||||
|
||||
func repositoryPath(parts ...string) string {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
return filepath.Join(append([]string{filepath.Dir(file), "..", ".."}, parts...)...)
|
||||
}
|
||||
|
||||
func readRepositoryFile(t *testing.T, parts ...string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(repositoryPath(parts...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
323
internal/cli/recompute_execution_contract_test.go
Normal file
323
internal/cli/recompute_execution_contract_test.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestRecomputeStepRecoversThroughFilesystemCheckpoints(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
invalidateOutput bool
|
||||
wantCode int
|
||||
}{
|
||||
{name: "accepted producer is hydrated", wantCode: 0},
|
||||
{name: "invalid producer stops dependents", invalidateOutput: true, wantCode: 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
roots := newRecomputeTestRoots(t)
|
||||
harness := newRecomputeTestHarness()
|
||||
fresh := runRecomputeCommand(roots, harness.options(), false)
|
||||
if fresh.code != 0 {
|
||||
t.Fatalf("fresh run code=%d stderr=%q", fresh.code, fresh.stderr)
|
||||
}
|
||||
removeCheckpointLaneStage(t, roots.checkpoints, "extract", "first", "producer")
|
||||
removeCheckpointLaneStage(t, roots.checkpoints, "merge", "first", "producer")
|
||||
if tt.invalidateOutput {
|
||||
path := findCheckpointFile(t, roots.checkpoints, "normalize", "first", "producer", "output.json")
|
||||
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
harness.resetCalls()
|
||||
|
||||
resumed := runRecomputeCommand(roots, harness.options(), true)
|
||||
if resumed.code != tt.wantCode {
|
||||
t.Fatalf("resumed code=%d stdout=%q stderr=%q", resumed.code, resumed.stdout, resumed.stderr)
|
||||
}
|
||||
events := readLatestCheckpointEvents(t, roots.debug)
|
||||
if tt.invalidateOutput {
|
||||
if harness.callsFor("test/extract/middle") != 0 || harness.callsFor("test/extract/dependent") != 0 {
|
||||
t.Fatalf("dependent calls after invalid producer = %#v", harness.callsSnapshot())
|
||||
}
|
||||
if !strings.Contains(resumed.stderr, string(pipeline.CheckpointReasonDecodeFailed)) {
|
||||
t.Fatalf("stderr=%q, want stable checkpoint reason", resumed.stderr)
|
||||
}
|
||||
assertNormalizeDecisionSequence(t, events, []checkpointDecisionExpectation{{"first", "producer", pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonDecodeFailed}})
|
||||
return
|
||||
}
|
||||
|
||||
if got := harness.callsSnapshot(); !reflect.DeepEqual(got, map[string]int{"test/extract/dependent": 1, "test/extract/middle": 1}) {
|
||||
t.Fatalf("resumed extractor calls = %#v", got)
|
||||
}
|
||||
outputPath := filepath.Join(latestChildDir(t, roots.output), "result.json")
|
||||
data, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "[\"producer\",\"unrelated\",\"middle\",\"dependent\"]\n" {
|
||||
t.Fatalf("ordered output = %q", data)
|
||||
}
|
||||
assertNormalizeDecisionSequence(t, events, []checkpointDecisionExpectation{
|
||||
{"first", "producer", pipeline.CheckpointDecisionReused, pipeline.CheckpointReasonAcceptedArtifactReused},
|
||||
{"first", "unrelated", pipeline.CheckpointDecisionReused, pipeline.CheckpointReasonReused},
|
||||
{"second", "middle", pipeline.CheckpointDecisionForcedRecompute, pipeline.CheckpointReasonRecomputeStep},
|
||||
{"third", "dependent", pipeline.CheckpointDecisionForcedRecompute, pipeline.CheckpointReasonRecomputeStep},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type checkpointDecisionExpectation struct {
|
||||
step, lane string
|
||||
action pipeline.CheckpointDecisionCategory
|
||||
reason pipeline.CheckpointReasonCode
|
||||
}
|
||||
|
||||
func assertNormalizeDecisionSequence(t *testing.T, events []pipeline.CheckpointEvent, want []checkpointDecisionExpectation) {
|
||||
t.Helper()
|
||||
var got []checkpointDecisionExpectation
|
||||
for _, event := range events {
|
||||
if event.Stage == string(pipeline.StageNormalize) {
|
||||
got = append(got, checkpointDecisionExpectation{event.StepID, event.LaneID, event.Action, event.ReasonCode})
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("normalize decisions = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type recomputeTestHarness struct {
|
||||
base *stateTestHarness
|
||||
mu sync.Mutex
|
||||
calls map[string]int
|
||||
}
|
||||
|
||||
func newRecomputeTestHarness() *recomputeTestHarness {
|
||||
return &recomputeTestHarness{base: newStateTestHarness(), calls: make(map[string]int)}
|
||||
}
|
||||
|
||||
func (h *recomputeTestHarness) options() Options {
|
||||
opts := h.base.options()
|
||||
for _, key := range []string{"test/extract/producer", "test/extract/unrelated", "test/extract/middle", "test/extract/dependent"} {
|
||||
moduleKey := key
|
||||
spec := pipeline.ModuleSpec{
|
||||
Key: moduleKey, Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: stateTestArtifactKind,
|
||||
ReferenceSlots: []contracts.ReferenceSlot{{Name: "upstream", AcceptedMediaTypes: []string{"application/json"}, AcceptedArtifactKinds: []contracts.ArtifactKind{stateTestArtifactKind}}},
|
||||
}
|
||||
if err := pipeline.RegisterExtractor(opts.Registries.Extractors, spec, func() (contracts.Extractor[stateTestArtifact], error) {
|
||||
return recomputeTestExtractor{key: moduleKey, harness: h}, nil
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
if err := opts.Registries.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "test/recompute-output", Stage: pipeline.StageOutput, Requires: []string{"normalized"}, Provides: []string{"output"}}, func() (contracts.OutputEncoder, error) {
|
||||
return recomputeTestOutput{}, nil
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
opts.Catalog = catalogFromRegistries(opts.Registries)
|
||||
return opts
|
||||
}
|
||||
|
||||
func (h *recomputeTestHarness) record(key string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.calls[key]++
|
||||
}
|
||||
|
||||
func (h *recomputeTestHarness) resetCalls() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.calls = make(map[string]int)
|
||||
}
|
||||
|
||||
func (h *recomputeTestHarness) callsFor(key string) int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.calls[key]
|
||||
}
|
||||
|
||||
func (h *recomputeTestHarness) callsSnapshot() map[string]int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
result := make(map[string]int, len(h.calls))
|
||||
for key, value := range h.calls {
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type recomputeTestExtractor struct {
|
||||
key string
|
||||
harness *recomputeTestHarness
|
||||
}
|
||||
|
||||
func (e recomputeTestExtractor) Key() string { return e.key }
|
||||
func (e recomputeTestExtractor) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
return []contracts.ReferenceSlot{{Name: "upstream", AcceptedMediaTypes: []string{"application/json"}, AcceptedArtifactKinds: []contracts.ArtifactKind{stateTestArtifactKind}}}
|
||||
}
|
||||
func (e recomputeTestExtractor) Extract(context.Context, contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[stateTestArtifact], error) {
|
||||
e.harness.record(e.key)
|
||||
return contracts.TypedExtractionResult[stateTestArtifact]{Value: stateTestArtifact{Value: e.key}}, nil
|
||||
}
|
||||
|
||||
type recomputeTestOutput struct{}
|
||||
|
||||
func (recomputeTestOutput) Key() string { return "test/recompute-output" }
|
||||
func (recomputeTestOutput) Encode(_ context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) {
|
||||
lanes := make([]string, 0, len(req.NormalizeOutputs))
|
||||
for _, output := range req.NormalizeOutputs {
|
||||
lanes = append(lanes, output.LaneID)
|
||||
}
|
||||
data, err := json.Marshal(lanes)
|
||||
if err != nil {
|
||||
return contracts.OutputResult{}, err
|
||||
}
|
||||
return contracts.OutputResult{Files: []contracts.OutputFile{{Name: "result.json", Bytes: append(data, '\n')}}}, nil
|
||||
}
|
||||
|
||||
func newRecomputeTestRoots(t *testing.T) stateTestRoots {
|
||||
t.Helper()
|
||||
roots := newStateTestRoots(t)
|
||||
config := fmt.Sprintf(`version: 3
|
||||
output:
|
||||
directory: %q
|
||||
cache:
|
||||
chunk_plans:
|
||||
directory: %q
|
||||
mode: bypass
|
||||
checkpoints:
|
||||
enabled: true
|
||||
directory: %q
|
||||
debug:
|
||||
directory: %q
|
||||
pipelines:
|
||||
sample:
|
||||
input: test/input
|
||||
chunk: test/chunk
|
||||
steps:
|
||||
- id: first
|
||||
artifacts:
|
||||
producer:
|
||||
extract: test/extract/producer
|
||||
merge: test/merge
|
||||
normalize: test/normalize
|
||||
unrelated:
|
||||
extract: test/extract/unrelated
|
||||
merge: test/merge
|
||||
normalize: test/normalize
|
||||
- id: second
|
||||
references:
|
||||
upstream:
|
||||
artifact:
|
||||
step: first
|
||||
lane: producer
|
||||
artifacts:
|
||||
middle:
|
||||
extract: test/extract/middle
|
||||
merge: test/merge
|
||||
normalize: test/normalize
|
||||
- id: third
|
||||
references:
|
||||
upstream:
|
||||
artifact:
|
||||
step: second
|
||||
lane: middle
|
||||
artifacts:
|
||||
dependent:
|
||||
extract: test/extract/dependent
|
||||
merge: test/merge
|
||||
normalize: test/normalize
|
||||
output: test/recompute-output
|
||||
`, roots.output, roots.plans, roots.checkpoints, roots.debug)
|
||||
if err := os.WriteFile(roots.config, []byte(config), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func runRecomputeCommand(roots stateTestRoots, opts Options, recompute bool) stateTestResult {
|
||||
args := []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}
|
||||
if recompute {
|
||||
args = append(args, "--resume", "--recompute-step", "second", "--debug")
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
return stateTestResult{code: RunWithOptions(args, &stdout, &stderr, opts), stdout: stdout.String(), stderr: stderr.String()}
|
||||
}
|
||||
|
||||
func removeCheckpointLaneStage(t *testing.T, root, stage, step, lane string) {
|
||||
t.Helper()
|
||||
dir := filepath.Dir(findCheckpointFile(t, root, stage, step, lane, "manifest.json"))
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func findCheckpointFile(t *testing.T, root, stage, step, lane, name string) string {
|
||||
t.Helper()
|
||||
want := filepath.Join(stage, step, lane, name)
|
||||
var matches []string
|
||||
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !entry.IsDir() && strings.HasSuffix(path, want) {
|
||||
matches = append(matches, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
t.Fatalf("checkpoint files ending in %q = %v", want, matches)
|
||||
}
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
func latestChildDir(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var dirs []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
dirs = append(dirs, filepath.Join(root, entry.Name()))
|
||||
}
|
||||
}
|
||||
if len(dirs) == 0 {
|
||||
t.Fatal("no child directory")
|
||||
}
|
||||
sort.Strings(dirs)
|
||||
return dirs[len(dirs)-1]
|
||||
}
|
||||
|
||||
func readLatestCheckpointEvents(t *testing.T, root string) []pipeline.CheckpointEvent {
|
||||
t.Helper()
|
||||
var events []pipeline.CheckpointEvent
|
||||
data, err := os.ReadFile(filepath.Join(latestChildDir(t, root), "summary", "checkpoint-events.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &events); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return events
|
||||
}
|
||||
56
internal/cli/recompute_policy_test.go
Normal file
56
internal/cli/recompute_policy_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestRecomputePolicyIncludesDependentLanesAndReusablePredecessors(t *testing.T) {
|
||||
producer := pipeline.ResolvedArtifactLane{StepID: "first", ID: "producer"}
|
||||
unrelated := pipeline.ResolvedArtifactLane{StepID: "first", ID: "unrelated"}
|
||||
consumer := pipeline.ResolvedArtifactLane{
|
||||
StepID: "second", ID: "consumer",
|
||||
ExtractReferences: pipeline.ResolvedReferenceTarget{Bindings: []pipeline.ReferenceBinding{{Artifact: &pipeline.ArtifactReference{Step: "first", Lane: "producer"}}}},
|
||||
}
|
||||
downstream := pipeline.ResolvedArtifactLane{
|
||||
StepID: "third", ID: "downstream",
|
||||
ExtractReferences: pipeline.ResolvedReferenceTarget{Bindings: []pipeline.ReferenceBinding{{Artifact: &pipeline.ArtifactReference{Step: "second", Lane: "consumer"}}}},
|
||||
}
|
||||
independent := pipeline.ResolvedArtifactLane{StepID: "third", ID: "independent"}
|
||||
resolved := pipeline.ResolvedPipeline{Steps: []pipeline.ResolvedPipelineStep{
|
||||
{ID: "first", ArtifactLanes: []pipeline.ResolvedArtifactLane{producer, unrelated}},
|
||||
{ID: "second", ArtifactLanes: []pipeline.ResolvedArtifactLane{consumer}},
|
||||
{ID: "third", ArtifactLanes: []pipeline.ResolvedArtifactLane{downstream, independent}},
|
||||
}}
|
||||
|
||||
policy, err := recomputePolicy(resolved, "second")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := policy.ForcedLanes[pipeline.CheckpointLaneKey("second", "consumer")]; !ok {
|
||||
t.Fatal("selected lane was not forced")
|
||||
}
|
||||
if _, ok := policy.ForcedLanes[pipeline.CheckpointLaneKey("third", "downstream")]; !ok {
|
||||
t.Fatal("transitive dependent lane was not forced")
|
||||
}
|
||||
if _, ok := policy.ForcedLanes[pipeline.CheckpointLaneKey("first", "producer")]; ok {
|
||||
t.Fatal("predecessor was implicitly forced")
|
||||
}
|
||||
if _, ok := policy.RequireReusableLanes[pipeline.CheckpointLaneKey("first", "producer")]; !ok {
|
||||
t.Fatal("required predecessor was not marked reusable")
|
||||
}
|
||||
if _, ok := policy.ForcedLanes[pipeline.CheckpointLaneKey("first", "unrelated")]; ok {
|
||||
t.Fatal("unrelated lane was forced")
|
||||
}
|
||||
if _, ok := policy.ForcedLanes[pipeline.CheckpointLaneKey("third", "independent")]; ok {
|
||||
t.Fatal("unrelated later lane was forced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputePolicyRejectsUnknownStep(t *testing.T) {
|
||||
_, err := recomputePolicy(pipeline.ResolvedPipeline{Steps: []pipeline.ResolvedPipelineStep{{ID: "known"}}}, "missing")
|
||||
if err == nil {
|
||||
t.Fatal("unknown step was accepted")
|
||||
}
|
||||
}
|
||||
456
internal/cli/reference_contract_test.go
Normal file
456
internal/cli/reference_contract_test.go
Normal file
@@ -0,0 +1,456 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestReferenceSelectorsParseAndApplyAllDocumentedForms(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
selector string
|
||||
only []string
|
||||
wantStage pipeline.ModuleStage
|
||||
wantLane string
|
||||
wantSlot string
|
||||
}{
|
||||
{name: "flat", selector: "alpha-slot", wantStage: pipeline.StageExtract, wantLane: "alpha", wantSlot: "alpha-slot"},
|
||||
{name: "chunk", selector: "chunk.chunk-slot", wantStage: pipeline.StageChunk, wantSlot: "chunk-slot"},
|
||||
{name: "merge", selector: "merge.alpha-merge", only: []string{"alpha"}, wantStage: pipeline.StageMerge, wantLane: "alpha", wantSlot: "alpha-merge"},
|
||||
{name: "lane", selector: "alpha.alpha-slot", wantStage: pipeline.StageExtract, wantLane: "alpha", wantSlot: "alpha-slot"},
|
||||
{name: "lane extract", selector: "alpha.extract.alpha-slot", wantStage: pipeline.StageExtract, wantLane: "alpha", wantSlot: "alpha-slot"},
|
||||
{name: "lane merge", selector: "alpha.merge.alpha-merge", wantStage: pipeline.StageMerge, wantLane: "alpha", wantSlot: "alpha-merge"},
|
||||
{name: "lane normalize", selector: "alpha.normalize.alpha-normalize", wantStage: pipeline.StageNormalize, wantLane: "alpha", wantSlot: "alpha-normalize"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := referenceContractConfig()
|
||||
catalog := referenceContractCatalog(t, true, true)
|
||||
selector, err := parseReferenceSelector(tt.selector, "--reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overrides, _, err := resolveCLIReferenceRequests(cfg, "demo", tt.only, catalog, []cliReferenceRequest{{Selector: selector, Source: "reference.txt"}}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve selector: %v", err)
|
||||
}
|
||||
if len(overrides) != 1 {
|
||||
t.Fatalf("overrides = %#v, want one binding", overrides)
|
||||
}
|
||||
got := overrides[0]
|
||||
if got.Stage != tt.wantStage || got.LaneID != tt.wantLane || got.SlotName != tt.wantSlot || got.BindingSource != contracts.ReferenceBindingSourceCLI {
|
||||
t.Fatalf("binding = %#v, want %s/%s/%s from CLI", got, tt.wantStage, tt.wantLane, tt.wantSlot)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceSelectorsRejectAmbiguityWithSpecificSuggestions(t *testing.T) {
|
||||
cfg := referenceContractConfig()
|
||||
catalog := referenceContractCatalog(t, true, true)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
selector string
|
||||
want []string
|
||||
}{
|
||||
{name: "flat shared slot", selector: "shared", want: []string{"alpha.extract.shared", "beta.extract.shared"}},
|
||||
{name: "lane shared slot", selector: "alpha.shared", want: []string{"alpha.extract.shared", "alpha.merge.shared", "alpha.normalize.shared"}},
|
||||
{name: "all mergers", selector: "merge.shared", want: []string{"alpha.merge.shared", "beta.merge.shared"}},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selector, err := parseReferenceSelector(tt.selector, "--reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = resolveCLIReferenceRequests(cfg, "demo", nil, catalog, []cliReferenceRequest{{Selector: selector, Source: "reference.txt"}}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("resolve selector succeeded, want ambiguity error")
|
||||
}
|
||||
for _, fragment := range tt.want {
|
||||
if !strings.Contains(err.Error(), fragment) {
|
||||
t.Fatalf("error = %q, want suggestion %q", err, fragment)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceSelectorsRespectSelectedLanesBeforeMaterialization(t *testing.T) {
|
||||
cfg := referenceContractConfig()
|
||||
catalog := referenceContractCatalog(t, true, true)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
selector string
|
||||
want string
|
||||
}{
|
||||
{name: "unselected lane", selector: "beta.extract.beta-slot", want: `reference lane "beta" is not selected`},
|
||||
{name: "unknown lane", selector: "missing.extract.beta-slot", want: `reference lane "missing" is not selected`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selector, err := parseReferenceSelector(tt.selector, "--reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = resolveCLIReferenceRequests(cfg, "demo", []string{"alpha"}, catalog, []cliReferenceRequest{{Selector: selector, Source: filepath.Join(t.TempDir(), "missing.txt")}}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) || strings.Contains(err.Error(), "missing.txt") {
|
||||
t.Fatalf("error = %v, want selection failure before file access", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceSyntaxErrorsReturnTwo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "reference missing value", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference"}},
|
||||
{name: "reference missing selector", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "=path.txt"}},
|
||||
{name: "reference missing separator", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "slot"}},
|
||||
{name: "reference missing path", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "slot="}},
|
||||
{name: "reference excess segments", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "a.b.c.d=path.txt"}},
|
||||
{name: "unbind with path", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--without-reference", "slot=path.txt"}},
|
||||
{name: "unbind excess segments", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--without-reference", "a.b.c.d"}},
|
||||
{name: "unbind missing value", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--without-reference"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions(tt.args, &stdout, &stderr, Options{LookupEnv: emptyLookup})
|
||||
if code != 2 || stdout.Len() != 0 || stderr.Len() == 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceOverridesUseFinalExactTargetBinding(t *testing.T) {
|
||||
cfg := referenceContractConfig()
|
||||
catalog := referenceContractCatalog(t, true, true)
|
||||
alphaShared, err := parseReferenceSelector("alpha.extract.shared", "--reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
betaShared, err := parseReferenceSelector("beta.extract.shared", "--reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overrides, unbinds, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, []cliReferenceRequest{
|
||||
{Selector: alphaShared, Source: "alpha-first.txt"},
|
||||
{Selector: alphaShared, Source: "alpha-final.txt"},
|
||||
{Selector: betaShared, Source: "beta-only.txt"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(unbinds) != 0 {
|
||||
t.Fatalf("unbinds = %#v, want none", unbinds)
|
||||
}
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceOverrides: overrides})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve pipeline: %v", err)
|
||||
}
|
||||
alpha := referenceContractLane(t, effective.ResolvedPipeline, "alpha")
|
||||
beta := referenceContractLane(t, effective.ResolvedPipeline, "beta")
|
||||
if source := referenceContractBindingSource(alpha.ExtractReferences.Bindings, "shared"); source != "alpha-final.txt" {
|
||||
t.Fatalf("alpha shared source = %q, want final exact-target override", source)
|
||||
}
|
||||
if source := referenceContractBindingSource(beta.ExtractReferences.Bindings, "shared"); source != "beta-only.txt" {
|
||||
t.Fatalf("beta shared source = %q, want target-specific override", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceUnbindsRemoveOptionalAndProtectRequiredSlots(t *testing.T) {
|
||||
cfg := referenceContractConfig()
|
||||
catalog := referenceContractCatalog(t, true, true)
|
||||
optional, err := parseReferenceSelector("alpha.extract.alpha-slot", "--without-reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, without, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, nil, []cliReferenceUnbindRequest{{Selector: optional}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceUnbinds: without})
|
||||
if err != nil {
|
||||
t.Fatalf("optional unbind: %v", err)
|
||||
}
|
||||
if binding := referenceContractFindBinding(referenceContractLane(t, effective.ResolvedPipeline, "alpha").ExtractReferences.Bindings, "alpha-slot"); binding != nil {
|
||||
t.Fatalf("optional binding after unbind = %#v, want absent", binding)
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
selector string
|
||||
}{
|
||||
{name: "chunk", selector: "chunk.required-chunk"},
|
||||
{name: "extract", selector: "alpha.extract.required-extract"},
|
||||
{name: "merge", selector: "alpha.merge.required-merge"},
|
||||
{name: "normalize", selector: "alpha.normalize.required-normalize"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selector, err := parseReferenceSelector(tt.selector, "--without-reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, unbinds, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, nil, []cliReferenceUnbindRequest{{Selector: selector}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceUnbinds: unbinds})
|
||||
if err == nil || !strings.Contains(err.Error(), "required reference slot") {
|
||||
t.Fatalf("resolve error = %v, want required-slot failure", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceMaterializationSeparatesCLIAndConfigPathOrigins(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
workingDir := t.TempDir()
|
||||
cfg := referenceContractConfig()
|
||||
configPath := filepath.Join(configDir, "config.yml")
|
||||
if err := os.WriteFile(configPath, []byte("version: 3\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "required.txt"), []byte("config reference"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "optional.txt"), []byte("optional reference"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workingDir, "cli-reference.txt"), []byte("CLI reference"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog := referenceContractCatalog(t, true, true)
|
||||
selector, err := parseReferenceSelector("alpha.extract.alpha-slot", "--reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overrides, unbinds, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, []cliReferenceRequest{{Selector: selector, Source: "cli-reference.txt"}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceOverrides: overrides, ReferenceUnbinds: unbinds})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve pipeline: %v", err)
|
||||
}
|
||||
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{ConfigPath: configPath, WorkingDir: workingDir})
|
||||
if err != nil {
|
||||
t.Fatalf("materialize references: %v", err)
|
||||
}
|
||||
alpha := referenceContractLane(t, materialized, "alpha")
|
||||
cliItem := alpha.ExtractReferences.ReferenceSet.Slots["alpha-slot"].Items[0]
|
||||
if string(cliItem.Content) != "CLI reference" || cliItem.BindingSource != contracts.ReferenceBindingSourceCLI || cliItem.Origin.URI != referenceContractFileURI(filepath.Join(workingDir, "cli-reference.txt")) {
|
||||
t.Fatalf("CLI materialization = %#v, want working-directory provenance", cliItem)
|
||||
}
|
||||
configItem := alpha.ExtractReferences.ReferenceSet.Slots["required-extract"].Items[0]
|
||||
if string(configItem.Content) != "config reference" || configItem.BindingSource != contracts.ReferenceBindingSourceConfig || configItem.Origin.URI != referenceContractFileURI(filepath.Join(configDir, "required.txt")) {
|
||||
t.Fatalf("config materialization = %#v, want config-directory provenance", configItem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceTargetLookupUsesArtifactVariantsAndReportsMissingContext(t *testing.T) {
|
||||
cfg := referenceContractConfig()
|
||||
full := referenceContractCatalog(t, true, true)
|
||||
targets, err := selectedReferenceTargets(cfg, "demo", nil, full)
|
||||
if err != nil {
|
||||
t.Fatalf("select reference targets: %v", err)
|
||||
}
|
||||
var alphaMerge, betaMerge selectedReferenceTarget
|
||||
for _, target := range targets {
|
||||
if target.stage == pipeline.StageMerge && target.laneID == "alpha" {
|
||||
alphaMerge = target
|
||||
}
|
||||
if target.stage == pipeline.StageMerge && target.laneID == "beta" {
|
||||
betaMerge = target
|
||||
}
|
||||
}
|
||||
if _, ok := alphaMerge.slots["alpha-merge"]; !ok {
|
||||
t.Fatalf("alpha merger slots = %#v, want alpha artifact variant", alphaMerge.slots)
|
||||
}
|
||||
if _, ok := betaMerge.slots["beta-merge"]; !ok {
|
||||
t.Fatalf("beta merger slots = %#v, want beta artifact variant", betaMerge.slots)
|
||||
}
|
||||
if _, ok := betaMerge.slots["alpha-merge"]; ok {
|
||||
t.Fatalf("beta merger slots = %#v, must not use alpha variant", betaMerge.slots)
|
||||
}
|
||||
|
||||
missingMerger := referenceContractCatalog(t, false, true)
|
||||
_, err = selectedReferenceTargets(cfg, "demo", nil, missingMerger)
|
||||
if err == nil || !strings.Contains(err.Error(), "merger") || !strings.Contains(err.Error(), string(referenceContractKindBeta)) {
|
||||
t.Fatalf("missing merger error = %v, want artifact variant context", err)
|
||||
}
|
||||
missingNormalizer := referenceContractCatalog(t, true, false)
|
||||
_, err = selectedReferenceTargets(cfg, "demo", nil, missingNormalizer)
|
||||
if err == nil || !strings.Contains(err.Error(), "normalizer") || !strings.Contains(err.Error(), string(referenceContractKindBeta)) {
|
||||
t.Fatalf("missing normalizer error = %v, want artifact variant context", err)
|
||||
}
|
||||
missingExtractor := referenceContractCatalog(t, true, true)
|
||||
missingExtractor.Extractors = pipeline.NewExtractorRegistry()
|
||||
_, err = selectedReferenceTargets(cfg, "demo", nil, missingExtractor)
|
||||
if err == nil || !strings.Contains(err.Error(), `lane "alpha" extract module`) || !strings.Contains(err.Error(), "not registered") {
|
||||
t.Fatalf("missing extractor error = %v, want lane/module context", err)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
referenceContractKindAlpha contracts.ArtifactKind = "reference/alpha"
|
||||
referenceContractKindBeta contracts.ArtifactKind = "reference/beta"
|
||||
)
|
||||
|
||||
func referenceContractConfig() config.Config {
|
||||
cfg := config.Default()
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{
|
||||
"demo": {
|
||||
ID: "demo",
|
||||
Input: pipeline.Binding("reference/input"),
|
||||
Chunk: pipeline.Binding("reference/chunk"),
|
||||
Output: pipeline.Binding("reference/output"),
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"alpha": {
|
||||
Extract: pipeline.Binding("reference/extract-alpha"),
|
||||
Merge: pipeline.Binding("reference/shared-merge"),
|
||||
Normalize: pipeline.Binding("reference/shared-normalize"),
|
||||
References: pipeline.ExternalReferenceMap(map[string]string{"required-extract": "required.txt"}),
|
||||
},
|
||||
"beta": {
|
||||
Extract: pipeline.Binding("reference/extract-beta"),
|
||||
Merge: pipeline.Binding("reference/shared-merge"),
|
||||
Normalize: pipeline.Binding("reference/shared-normalize"),
|
||||
References: pipeline.ExternalReferenceMap(map[string]string{"required-extract": "required.txt"}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
profile := cfg.Pipelines["demo"]
|
||||
profile.Chunk.References = pipeline.ExternalReferenceMap(map[string]string{"required-chunk": "required.txt"})
|
||||
alpha := profile.Artifacts["alpha"]
|
||||
alpha.Extract.References = pipeline.ExternalReferenceMap(map[string]string{"required-extract": "required.txt", "alpha-slot": "optional.txt"})
|
||||
alpha.Merge.References = pipeline.ExternalReferenceMap(map[string]string{"required-merge": "required.txt"})
|
||||
alpha.Normalize.References = pipeline.ExternalReferenceMap(map[string]string{"required-normalize": "required.txt"})
|
||||
profile.Artifacts["alpha"] = alpha
|
||||
beta := profile.Artifacts["beta"]
|
||||
beta.Extract.References = pipeline.ExternalReferenceMap(map[string]string{"required-extract": "required.txt"})
|
||||
beta.Merge.References = pipeline.ExternalReferenceMap(map[string]string{"required-merge": "required.txt"})
|
||||
beta.Normalize.References = pipeline.ExternalReferenceMap(map[string]string{"required-normalize": "required.txt"})
|
||||
profile.Artifacts["beta"] = beta
|
||||
cfg.Pipelines["demo"] = profile
|
||||
return cfg
|
||||
}
|
||||
|
||||
func referenceContractCatalog(t *testing.T, includeBetaMerger, includeBetaNormalizer bool) pipeline.ModuleCatalog {
|
||||
t.Helper()
|
||||
registries := pipeline.Registries{
|
||||
Inputs: pipeline.NewInputAdapterRegistry(),
|
||||
Chunkers: pipeline.NewChunkerRegistry(),
|
||||
ArtifactCodecs: pipeline.NewArtifactCodecRegistry(),
|
||||
Extractors: pipeline.NewExtractorRegistry(),
|
||||
Mergers: pipeline.NewMergerRegistry(),
|
||||
Normalizers: pipeline.NewNormalizerRegistry(),
|
||||
Validators: pipeline.NewValidatorRegistry(),
|
||||
ValidatorChains: pipeline.NewValidatorChainRegistry(),
|
||||
Outputs: pipeline.NewOutputEncoderRegistry(),
|
||||
}
|
||||
register := func(err error) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
register(registries.Inputs.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "reference/input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.InputAdapter, error) { return stateTestInput{}, nil }))
|
||||
register(registries.Chunkers.RegisterWithSpec(pipeline.ModuleSpec{Key: "reference/chunk", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "chunk-slot"}, {Name: "required-chunk", Required: true}}}, func() (contracts.Chunker, error) { return stateTestChunker{}, nil }))
|
||||
register(pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, referenceContractCodecA{}))
|
||||
register(pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, referenceContractCodecB{}))
|
||||
register(pipeline.RegisterExtractor(registries.Extractors, pipeline.ModuleSpec{Key: "reference/extract-alpha", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: referenceContractKindAlpha, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "alpha-slot"}, {Name: "required-extract", Required: true}}}, func() (contracts.Extractor[stateTestArtifact], error) { return stateTestExtractor{}, nil }))
|
||||
register(pipeline.RegisterExtractor(registries.Extractors, pipeline.ModuleSpec{Key: "reference/extract-beta", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: referenceContractKindBeta, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "beta-slot"}, {Name: "required-extract", Required: true}}}, func() (contracts.Extractor[stateTestArtifact], error) { return stateTestExtractor{}, nil }))
|
||||
register(pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "reference/shared-merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: referenceContractKindAlpha, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "alpha-merge"}, {Name: "required-merge", Required: true}}}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{}, nil }))
|
||||
if includeBetaMerger {
|
||||
register(pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "reference/shared-merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: referenceContractKindBeta, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "beta-merge"}, {Name: "required-merge", Required: true}}}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{}, nil }))
|
||||
}
|
||||
register(pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "reference/shared-normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: referenceContractKindAlpha, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "alpha-normalize"}, {Name: "required-normalize", Required: true}}}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{}, nil }))
|
||||
if includeBetaNormalizer {
|
||||
register(pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "reference/shared-normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: referenceContractKindBeta, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "beta-normalize"}, {Name: "required-normalize", Required: true}}}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{}, nil }))
|
||||
}
|
||||
register(registries.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "reference/output", Stage: pipeline.StageOutput, Requires: []string{"normalized"}, Provides: []string{"output"}}, func() (contracts.OutputEncoder, error) { return stateTestOutput{}, nil }))
|
||||
return catalogFromRegistries(registries)
|
||||
}
|
||||
|
||||
type referenceContractCodecB struct{}
|
||||
|
||||
type referenceContractCodecA struct{}
|
||||
|
||||
func (referenceContractCodecA) Kind() contracts.ArtifactKind { return referenceContractKindAlpha }
|
||||
func (referenceContractCodecA) Schema() contracts.ArtifactSchema {
|
||||
return contracts.ArtifactSchema{ID: "reference.alpha", Name: "reference_alpha", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)}
|
||||
}
|
||||
func (referenceContractCodecA) MediaType() string { return "application/json" }
|
||||
func (referenceContractCodecA) EncodeCandidate(stateTestArtifact) ([]byte, error) {
|
||||
return []byte(`{"value":"ok"}`), nil
|
||||
}
|
||||
func (referenceContractCodecA) Encode(stateTestArtifact) ([]byte, error) {
|
||||
return []byte(`{"value":"ok"}`), nil
|
||||
}
|
||||
func (referenceContractCodecA) Decode([]byte) (stateTestArtifact, error) {
|
||||
return stateTestArtifact{Value: "ok"}, nil
|
||||
}
|
||||
|
||||
func (referenceContractCodecB) Kind() contracts.ArtifactKind { return referenceContractKindBeta }
|
||||
func (referenceContractCodecB) Schema() contracts.ArtifactSchema {
|
||||
return contracts.ArtifactSchema{ID: "reference.beta", Name: "reference_beta", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)}
|
||||
}
|
||||
func (referenceContractCodecB) MediaType() string { return "application/json" }
|
||||
func (referenceContractCodecB) EncodeCandidate(stateTestArtifact) ([]byte, error) {
|
||||
return []byte(`{"value":"ok"}`), nil
|
||||
}
|
||||
func (referenceContractCodecB) Encode(stateTestArtifact) ([]byte, error) {
|
||||
return []byte(`{"value":"ok"}`), nil
|
||||
}
|
||||
func (referenceContractCodecB) Decode([]byte) (stateTestArtifact, error) {
|
||||
return stateTestArtifact{Value: "ok"}, nil
|
||||
}
|
||||
|
||||
func referenceContractLane(t *testing.T, resolved pipeline.ResolvedPipeline, id string) pipeline.ResolvedArtifactLane {
|
||||
t.Helper()
|
||||
for _, step := range resolved.Steps {
|
||||
for _, lane := range step.ArtifactLanes {
|
||||
if lane.ID == id {
|
||||
return lane
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("lane %q not found", id)
|
||||
return pipeline.ResolvedArtifactLane{}
|
||||
}
|
||||
|
||||
func referenceContractBindingSource(bindings []pipeline.ReferenceBinding, slot string) string {
|
||||
for _, binding := range bindings {
|
||||
if binding.SlotName == slot {
|
||||
return binding.Source
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func referenceContractFindBinding(bindings []pipeline.ReferenceBinding, slot string) *pipeline.ReferenceBinding {
|
||||
for i := range bindings {
|
||||
if bindings[i].SlotName == slot {
|
||||
return &bindings[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func referenceContractFileURI(path string) string {
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
absolute = path
|
||||
}
|
||||
return "file://" + filepath.ToSlash(absolute)
|
||||
}
|
||||
1202
internal/cli/run.go
1202
internal/cli/run.go
File diff suppressed because it is too large
Load Diff
501
internal/cli/run_contract_test.go
Normal file
501
internal/cli/run_contract_test.go
Normal file
@@ -0,0 +1,501 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestRunControlsRejectSyntaxWithoutAllocatingState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args func(stateTestRoots) []string
|
||||
}{
|
||||
{name: "missing pipeline", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "--config", roots.config, "--input", roots.input}
|
||||
}},
|
||||
{name: "missing input", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config}
|
||||
}},
|
||||
{name: "unknown flag", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--unknown"}
|
||||
}},
|
||||
{name: "blank output directory", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--output-dir", ""}
|
||||
}},
|
||||
{name: "blank debug directory", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--debug-dir", ""}
|
||||
}},
|
||||
{name: "debug directory without debug", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--debug-dir", filepath.Join(filepath.Dir(roots.debug), "requested-debug")}
|
||||
}},
|
||||
{name: "blank session ID", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--session-id", ""}
|
||||
}},
|
||||
{name: "multiple pipeline IDs", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "extra", "--config", roots.config, "--input", roots.input}
|
||||
}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions(tt.args(roots), &stdout, &stderr, newStateTestHarness().options())
|
||||
if code != 2 || stdout.Len() != 0 || stderr.Len() == 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertAbsent(t, roots.output)
|
||||
assertAbsent(t, roots.debug)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeStepCLIContract(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configure func(*testing.T, stateTestRoots)
|
||||
flags []string
|
||||
wantCode int
|
||||
wantOutput string
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "explicit step",
|
||||
configure: func(t *testing.T, roots stateTestRoots) {
|
||||
replaceStateTestConfigLine(t, roots.config, " artifacts:\n items:\n extract: test/extract\n merge: test/merge\n normalize: test/normalize\n", " steps:\n - id: chosen\n artifacts:\n items:\n extract: test/extract\n merge: test/merge\n normalize: test/normalize\n")
|
||||
},
|
||||
flags: []string{"--resume", "--recompute-step", "chosen"},
|
||||
wantCode: 0,
|
||||
wantOutput: "outputs=1",
|
||||
},
|
||||
{name: "implicit default step", flags: []string{"--resume", "--recompute-step", "default"}, wantCode: 0, wantOutput: "outputs=1"},
|
||||
{name: "repeated flag", flags: []string{"--resume", "--recompute-step", "default", "--recompute-step", "default"}, wantCode: 2, wantError: "specified only once"},
|
||||
{name: "empty step", flags: []string{"--resume", "--recompute-step", ""}, wantCode: 2, wantError: "must not be empty"},
|
||||
{name: "unknown step", flags: []string{"--resume", "--recompute-step", "missing"}, wantCode: 1, wantError: "unknown pipeline step"},
|
||||
{name: "without resume", flags: []string{"--recompute-step", "default"}, wantCode: 2, wantError: "requires --resume"},
|
||||
{
|
||||
name: "checkpoint recording disabled",
|
||||
configure: func(t *testing.T, roots stateTestRoots) {
|
||||
replaceStateTestConfigLine(t, roots.config, " enabled: true\n", " enabled: false\n")
|
||||
},
|
||||
flags: []string{"--resume", "--recompute-step", "default"}, wantCode: 1, wantError: "cache.checkpoints.enabled",
|
||||
},
|
||||
{name: "with only", flags: []string{"--resume", "--recompute-step", "default", "--only", "items"}, wantCode: 2, wantError: "cannot be combined with --only"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
if tt.configure != nil {
|
||||
tt.configure(t, roots)
|
||||
}
|
||||
args := []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}
|
||||
args = append(args, tt.flags...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions(args, &stdout, &stderr, newStateTestHarness().options())
|
||||
if code != tt.wantCode || (tt.wantOutput != "" && !strings.Contains(stdout.String(), tt.wantOutput)) || (tt.wantError != "" && !strings.Contains(stderr.String(), tt.wantError)) {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidFailuresClassifyAndReportDebug(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args func(stateTestRoots) []string
|
||||
wantError string
|
||||
wantDebug bool
|
||||
}{
|
||||
{name: "unknown pipeline", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "missing", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}
|
||||
}, wantError: `pipeline "missing"`},
|
||||
{name: "unknown lane", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--only", "missing", "--chunk_cache", "bypass", "--debug"}
|
||||
}, wantError: `lane "missing"`, wantDebug: true},
|
||||
{name: "unreadable input", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", filepath.Join(filepath.Dir(roots.input), "unreadable.txt"), "--chunk_cache", "bypass", "--debug"}
|
||||
}, wantError: "read input", wantDebug: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions(tt.args(roots), &stdout, &stderr, newStateTestHarness().options())
|
||||
if code != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), tt.wantError) {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if tt.wantDebug {
|
||||
if !strings.Contains(stderr.String(), "debug=") {
|
||||
t.Fatalf("stderr=%q, want debug path", stderr.String())
|
||||
}
|
||||
onlyChildDir(t, roots.debug)
|
||||
} else {
|
||||
assertAbsent(t, roots.debug)
|
||||
}
|
||||
assertAbsent(t, roots.output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOnlyExecutesSelectedLanes(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
data, err := os.ReadFile(roots.config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = []byte(replaceRequiredOnce(t, string(data), " output: test/output\n", " other:\n extract: test/extract\n output: test/output\n"))
|
||||
if err := os.WriteFile(roots.config, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
harness := newStateTestHarness()
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--only", "items", "--chunk_cache", "bypass"}, &stdout, &stderr, harness.options())
|
||||
if code != 0 || !strings.Contains(stdout.String(), "outputs=1") || stderr.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
harness.mu.Lock()
|
||||
extractCalls := harness.extractCalls
|
||||
harness.mu.Unlock()
|
||||
if extractCalls != 1 {
|
||||
t.Fatalf("extract calls = %d, want only the selected lane", extractCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStateRootsHonorEnvironmentFlagsAndDefaults(t *testing.T) {
|
||||
t.Run("environment roots", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
environmentOutput := filepath.Join(t.TempDir(), "environment-output")
|
||||
environmentDebug := filepath.Join(t.TempDir(), "environment-debug")
|
||||
opts := newStateTestHarness().options()
|
||||
opts.LookupEnv = lookupRunContractEnv(map[string]string{
|
||||
"NOTARIUS_OUTPUT_DIR": environmentOutput,
|
||||
"NOTARIUS_DEBUG_DIR": environmentDebug,
|
||||
})
|
||||
result := runWithStateRoots(t, roots, opts, nil)
|
||||
if result.code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
|
||||
}
|
||||
assertFile(t, filepath.Join(environmentOutput, filepath.Base(onlyChildDir(t, environmentOutput)), "result.json"))
|
||||
onlyChildDir(t, environmentDebug)
|
||||
assertAbsent(t, roots.output)
|
||||
assertAbsent(t, roots.debug)
|
||||
})
|
||||
|
||||
t.Run("command flags override environment", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
environmentOutput := filepath.Join(t.TempDir(), "environment-output")
|
||||
environmentDebug := filepath.Join(t.TempDir(), "environment-debug")
|
||||
flagOutput := filepath.Join(t.TempDir(), "flag-output")
|
||||
flagDebug := filepath.Join(t.TempDir(), "flag-debug")
|
||||
opts := newStateTestHarness().options()
|
||||
opts.LookupEnv = lookupRunContractEnv(map[string]string{
|
||||
"NOTARIUS_OUTPUT_DIR": environmentOutput,
|
||||
"NOTARIUS_DEBUG_DIR": environmentDebug,
|
||||
})
|
||||
result := runWithStateRoots(t, roots, opts, []string{"--output-dir", flagOutput, "--debug-dir", flagDebug})
|
||||
if result.code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
|
||||
}
|
||||
assertFile(t, filepath.Join(flagOutput, filepath.Base(onlyChildDir(t, flagOutput)), "result.json"))
|
||||
onlyChildDir(t, flagDebug)
|
||||
assertAbsent(t, environmentOutput)
|
||||
assertAbsent(t, environmentDebug)
|
||||
})
|
||||
|
||||
t.Run("built-in roots", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
data, err := os.ReadFile(roots.config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
text = replaceRequiredOnce(t, text, fmt.Sprintf(" directory: %q\n", roots.output), "")
|
||||
text = replaceRequiredOnce(t, text, fmt.Sprintf(" directory: %q\n", roots.debug), "")
|
||||
if err := os.WriteFile(roots.config, []byte(text), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workDir := t.TempDir()
|
||||
t.Chdir(workDir)
|
||||
opts := newStateTestHarness().options()
|
||||
result := runWithStateRoots(t, roots, opts, nil)
|
||||
if result.code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
|
||||
}
|
||||
assertFile(t, filepath.Join(workDir, "notarius-output", filepath.Base(onlyChildDir(t, filepath.Join(workDir, "notarius-output"))), "result.json"))
|
||||
onlyChildDir(t, filepath.Join(workDir, "notarius-debug"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunLLMProfileOverrideAndValidationUseInjectedBoundaries(t *testing.T) {
|
||||
t.Run("one effective profile reaches the factory and modules", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
profileDir := writeRunContractProfiles(t, "override-profile")
|
||||
prependRunContractConfig(t, roots, fmt.Sprintf("scriptorium:\n profile_dir: %q\n", profileDir))
|
||||
harness := newStateTestHarness()
|
||||
var factoryProfiles []string
|
||||
opts := harness.options()
|
||||
opts.LLMClientFactory = func(_ context.Context, _ config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
factoryProfiles = append(factoryProfiles, profileID)
|
||||
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)
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if len(factoryProfiles) != 1 || factoryProfiles[0] != "override-profile" {
|
||||
t.Fatalf("factory profiles = %#v, want one override profile", factoryProfiles)
|
||||
}
|
||||
harness.mu.Lock()
|
||||
profiles := append([]string(nil), harness.moduleProfiles...)
|
||||
harness.mu.Unlock()
|
||||
if len(profiles) < 4 {
|
||||
t.Fatalf("module profiles = %#v, want chunk and lane stage requests", profiles)
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
if profile != "override-profile" {
|
||||
t.Fatalf("module profiles = %#v, want override on every request", profiles)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validator profile remains distinct", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
profileDir := writeRunContractProfiles(t, "override-profile", "validator-profile")
|
||||
prependRunContractConfig(t, roots, fmt.Sprintf("scriptorium:\n profile_dir: %q\n", profileDir))
|
||||
harness := newStateTestHarness()
|
||||
var validatorProfiles []string
|
||||
opts := harness.options()
|
||||
registerRunContractValidator(t, &opts, &validatorProfiles)
|
||||
factoryProfiles := []string{}
|
||||
opts.LLMClientFactory = func(_ context.Context, _ config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
factoryProfiles = append(factoryProfiles, profileID)
|
||||
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)
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if len(factoryProfiles) != 1 || factoryProfiles[0] != "" {
|
||||
t.Fatalf("factory profiles = %#v, want one call without a unique profile", factoryProfiles)
|
||||
}
|
||||
if len(validatorProfiles) != 1 || validatorProfiles[0] != "validator-profile" {
|
||||
t.Fatalf("validator profiles = %#v, want configured validator profile", validatorProfiles)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown profile is rejected without factory access", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
profileDir := writeRunContractProfiles(t, "override-profile")
|
||||
prependRunContractConfig(t, roots, fmt.Sprintf("scriptorium:\n profile_dir: %q\n", profileDir))
|
||||
factoryCalls := 0
|
||||
opts := newStateTestHarness().options()
|
||||
opts.LLMClientFactory = func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
factoryCalls++
|
||||
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", "missing-profile"}, &stdout, &stderr, opts)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "not configured") || factoryCalls != 0 || stdout.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q factoryCalls=%d", code, stdout.String(), stderr.String(), factoryCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEffectiveLLMProfileIDsAreSortedDeduplicatedAndLLMOnly(t *testing.T) {
|
||||
resolved := pipeline.ResolvedPipeline{
|
||||
Input: pipeline.ModuleBinding{LLMProfile: "input-profile"},
|
||||
Chunk: pipeline.ModuleBinding{LLMProfile: " zeta "},
|
||||
Steps: []pipeline.ResolvedPipelineStep{{
|
||||
ID: "default",
|
||||
ArtifactLanes: []pipeline.ResolvedArtifactLane{{
|
||||
Extract: pipeline.ModuleBinding{LLMProfile: "alpha"},
|
||||
Merge: pipeline.ModuleBinding{LLMProfile: "zeta"},
|
||||
Normalize: pipeline.ModuleBinding{LLMProfile: " gamma "},
|
||||
}},
|
||||
}},
|
||||
ValidatorChains: []pipeline.ResolvedValidatorChain{{Validators: []pipeline.ResolvedValidator{
|
||||
{Binding: pipeline.ModuleBinding{LLMProfile: "deterministic-profile"}, ExecutionClass: contracts.ExecutionClassDeterministic},
|
||||
{Binding: pipeline.ModuleBinding{LLMProfile: "beta"}, ExecutionClass: contracts.ExecutionClassLLMBacked},
|
||||
}}},
|
||||
Output: pipeline.ModuleBinding{LLMProfile: "output-profile"},
|
||||
}
|
||||
got := effectiveLLMProfileIDs(resolved)
|
||||
want := []string{"alpha", "beta", "gamma", "zeta"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("effective profiles = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSessionIDUsesExplicitValueOrSourceDocumentID(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "source document", want: "source"},
|
||||
{name: "explicit trimmed value", args: []string{"--session-id", " explicit-session "}, want: "explicit-session"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
harness := newStateTestHarness()
|
||||
args := append([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, tt.args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions(args, &stdout, &stderr, harness.options())
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
harness.mu.Lock()
|
||||
sessions := append([]string(nil), harness.sessionIDs...)
|
||||
harness.mu.Unlock()
|
||||
if len(sessions) < 4 {
|
||||
t.Fatalf("session IDs = %#v, want all prompt-facing module requests", sessions)
|
||||
}
|
||||
for _, session := range sessions {
|
||||
if session != tt.want {
|
||||
t.Fatalf("session IDs = %#v, want %q", sessions, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFactoryAndPreparationFailuresAreProcessFailures(t *testing.T) {
|
||||
t.Run("LLM factory", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
opts := newStateTestHarness().options()
|
||||
opts.LLMClientFactory = func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
return nil, nil, errors.New("injected LLM factory failure")
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, &stdout, &stderr, opts)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "injected LLM factory failure") || stdout.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pipeline preparation", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
data, err := os.ReadFile(roots.config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = []byte(replaceRequiredOnce(t, string(data), "extract: test/extract", "extract: test/failing-extract"))
|
||||
if err := os.WriteFile(roots.config, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts := newStateTestHarness().options()
|
||||
if err := pipeline.RegisterExtractorBuilder(opts.Registries.Extractors, pipeline.ModuleSpec{Key: "test/failing-extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: stateTestArtifactKind}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.Extractor[stateTestArtifact], error) {
|
||||
return nil, errors.New("injected extractor construction failure")
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts.Catalog = catalogFromRegistries(opts.Registries)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, &stdout, &stderr, opts)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "injected extractor construction failure") || stdout.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunWarningsRemainSuccessfulAndReachDurableSurfaces(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
harness := newStateTestHarness()
|
||||
harness.includeWarnings = true
|
||||
harness.chunkWarnings = []contracts.Warning{{Scope: "chunk", ReasonCode: "contract-warning", Message: "warning retained"}}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug"}, &stdout, &stderr, harness.options())
|
||||
if code != 0 || !strings.Contains(stdout.String(), "outputs=1") || !strings.Contains(stderr.String(), "1 warning(s)") {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
outputPath := filepath.Join(onlyChildDir(t, roots.output), "result.json")
|
||||
output, err := os.ReadFile(outputPath)
|
||||
if err != nil || !strings.Contains(string(output), "contract-warning") {
|
||||
t.Fatalf("durable output = %q, %v", output, err)
|
||||
}
|
||||
bundle := onlyChildDir(t, roots.debug)
|
||||
var warnings []contracts.Warning
|
||||
readStateTestSummaryJSON(t, bundle, "warnings.json", &warnings)
|
||||
if len(warnings) != 1 || warnings[0].ReasonCode != "contract-warning" {
|
||||
t.Fatalf("debug warnings = %#v", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func runWithStateRoots(t *testing.T, roots stateTestRoots, opts Options, extra []string) stateTestResult {
|
||||
t.Helper()
|
||||
args := []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug"}
|
||||
args = append(args, extra...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
return stateTestResult{code: RunWithOptions(args, &stdout, &stderr, opts), stdout: stdout.String(), stderr: stderr.String()}
|
||||
}
|
||||
|
||||
func lookupRunContractEnv(values map[string]string) func(string) (string, bool) {
|
||||
return func(name string) (string, bool) {
|
||||
value, ok := values[name]
|
||||
return value, ok
|
||||
}
|
||||
}
|
||||
|
||||
func prependRunContractConfig(t *testing.T, roots stateTestRoots, prefix string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(roots.config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(roots.config, append([]byte(prefix), data...), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeRunContractProfiles(t *testing.T, ids ...string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, id := range ids {
|
||||
profile := fmt.Sprintf("id: %s\nendpoint: http://127.0.0.1:1/v1\nmodel: %s-model\n", id, id)
|
||||
if err := os.WriteFile(filepath.Join(dir, id+".yaml"), []byte(profile), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func registerRunContractValidator(t *testing.T, opts *Options, profiles *[]string) {
|
||||
t.Helper()
|
||||
if err := pipeline.RegisterTypedValidatorBuilder(opts.Registries.Validators, stateTestArtifactKind, pipeline.ValidatorSpec{Key: "run-contract-validator", ExecutionClass: contracts.ExecutionClassLLMBacked}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.TypedValidator[stateTestArtifact], error) {
|
||||
return runContractValidator{profiles: profiles}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := opts.Registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{Stage: pipeline.StageExtract, Module: "test/extract", Validators: []pipeline.ModuleBinding{{Module: "run-contract-validator", LLMProfile: "validator-profile"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts.Catalog = catalogFromRegistries(opts.Registries)
|
||||
}
|
||||
|
||||
type runContractValidator struct {
|
||||
profiles *[]string
|
||||
}
|
||||
|
||||
func (v runContractValidator) Name() string { return "run-contract-validator" }
|
||||
|
||||
func (v runContractValidator) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassLLMBacked
|
||||
}
|
||||
|
||||
func (v runContractValidator) Validate(_ context.Context, req contracts.TypedValidationRequest[stateTestArtifact]) (contracts.ValidationResult, error) {
|
||||
*v.profiles = append(*v.profiles, req.LLMProfile)
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
34
internal/cli/run_id.go
Normal file
34
internal/cli/run_id.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RunIDGenerator func(time.Time) (string, error)
|
||||
|
||||
func defaultRunIDGenerator(startedAt time.Time) (string, error) {
|
||||
var suffix [16]byte
|
||||
if _, err := io.ReadFull(rand.Reader, suffix[:]); err != nil {
|
||||
return "", fmt.Errorf("read random run ID suffix: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("run-%d-%s", startedAt.UnixNano(), hex.EncodeToString(suffix[:])), nil
|
||||
}
|
||||
|
||||
func validateRunID(runID string) error {
|
||||
if runID == "" {
|
||||
return fmt.Errorf("run ID must not be empty")
|
||||
}
|
||||
if runID != strings.TrimSpace(runID) {
|
||||
return fmt.Errorf("run ID %q must not have surrounding whitespace", runID)
|
||||
}
|
||||
if strings.ContainsAny(runID, `/\\`) || filepath.IsAbs(runID) || filepath.Clean(runID) != runID || runID == "." || runID == ".." {
|
||||
return fmt.Errorf("run ID %q must be one safe path component", runID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
87
internal/cli/run_id_test.go
Normal file
87
internal/cli/run_id_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func TestDefaultRunIDGeneratorProducesUniqueSafeIDs(t *testing.T) {
|
||||
startedAt := time.Unix(0, 123456789).UTC()
|
||||
pattern := regexp.MustCompile(`^run-123456789-[0-9a-f]{32}$`)
|
||||
seen := make(map[string]struct{}, 256)
|
||||
for i := 0; i < 256; i++ {
|
||||
runID, err := defaultRunIDGenerator(startedAt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !pattern.MatchString(runID) {
|
||||
t.Fatalf("run ID %q does not match production format", runID)
|
||||
}
|
||||
if err := validateRunID(runID); err != nil {
|
||||
t.Fatalf("run ID %q is not path-safe: %v", runID, err)
|
||||
}
|
||||
if _, exists := seen[runID]; exists {
|
||||
t.Fatalf("duplicate run ID %q", runID)
|
||||
}
|
||||
seen[runID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteOutputFilesSupportsNestedLogicalPaths(t *testing.T) {
|
||||
runPath := filepath.Join(t.TempDir(), "output", "run-safe")
|
||||
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"))
|
||||
if err != nil || string(data) != "result" {
|
||||
t.Fatalf("nested output = %q, %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteOutputFilesRejectsUnsafeNamesBeforeAllocatingRunDirectory(t *testing.T) {
|
||||
outputRoot := filepath.Join(t.TempDir(), "output")
|
||||
runPath := filepath.Join(outputRoot, "run-safe")
|
||||
for _, name := range []string{"", "../outside", "/absolute", `nested\\outside`, "nested/../outside"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := writeOutputFiles(runPath, []contracts.OutputFile{{Name: "safe.json"}, {Name: name}}); err == nil {
|
||||
t.Fatalf("writeOutputFiles accepted %q", name)
|
||||
}
|
||||
if _, err := os.Stat(outputRoot); !os.IsNotExist(err) {
|
||||
t.Fatalf("output root exists or stat failed after %q: %v", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteOutputFilesRetainsNewPartialDirectoryAndPreservesSibling(t *testing.T) {
|
||||
outputRoot := filepath.Join(t.TempDir(), "output")
|
||||
siblingPath := filepath.Join(outputRoot, "sibling")
|
||||
if err := os.MkdirAll(siblingPath, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sentinelPath := filepath.Join(siblingPath, "sentinel")
|
||||
if err := os.WriteFile(sentinelPath, []byte("preserve sibling"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runPath := filepath.Join(outputRoot, "run-safe")
|
||||
err := writeOutputFiles(runPath, []contracts.OutputFile{
|
||||
{Name: "blocked", Bytes: []byte("partial output")},
|
||||
{Name: "blocked/nested.json", Bytes: []byte("unreachable")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "create output directory") {
|
||||
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" {
|
||||
t.Fatalf("partial output = %q, %v", got, err)
|
||||
}
|
||||
if got, err := os.ReadFile(sentinelPath); err != nil || string(got) != "preserve sibling" {
|
||||
t.Fatalf("sibling sentinel = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
90
internal/cli/run_terminal.go
Normal file
90
internal/cli/run_terminal.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
type DebugTerminalWriter interface {
|
||||
WriteRunReport(debugbundle.RunReport) error
|
||||
WriteError(string) error
|
||||
}
|
||||
|
||||
type pipelineCommandState struct {
|
||||
report debugbundle.RunReport
|
||||
terminalized bool
|
||||
}
|
||||
|
||||
func newPipelineCommandState(runID, pipelineID, outputPath string) *pipelineCommandState {
|
||||
return &pipelineCommandState{report: debugbundle.RunReport{
|
||||
RunID: runID,
|
||||
PipelineID: pipelineID,
|
||||
OutputPath: outputPath,
|
||||
}}
|
||||
}
|
||||
|
||||
func (s *pipelineCommandState) setDebugPath(debugPath string) {
|
||||
if s != nil {
|
||||
s.report.DebugPath = debugPath
|
||||
}
|
||||
}
|
||||
|
||||
func (s *pipelineCommandState) observeOutput(output pipeline.RunOutput) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.report.OutputCount = len(output.NormalizeOutputs)
|
||||
s.report.RejectedCount = len(output.Rejected)
|
||||
s.report.WarningCount = len(output.Warnings)
|
||||
s.report.ValidationStatus = output.Manifest.ValidationStatus
|
||||
}
|
||||
|
||||
func (s *pipelineCommandState) terminalize(writer DebugTerminalWriter, primaryErr error) (error, error) {
|
||||
if s == nil || s.terminalized {
|
||||
return primaryErr, nil
|
||||
}
|
||||
s.terminalized = true
|
||||
if writer == nil {
|
||||
return primaryErr, nil
|
||||
}
|
||||
|
||||
report := s.report
|
||||
report.Succeeded = primaryErr == nil
|
||||
reportErr := writer.WriteRunReport(report)
|
||||
if reportErr != nil {
|
||||
reportErr = fmt.Errorf("write debug run report: %w", reportErr)
|
||||
if primaryErr == nil {
|
||||
primaryErr = reportErr
|
||||
reportErr = nil
|
||||
}
|
||||
}
|
||||
|
||||
var errorLogErr error
|
||||
if primaryErr != nil {
|
||||
if err := writer.WriteError(primaryErr.Error()); err != nil {
|
||||
errorLogErr = fmt.Errorf("write debug error log: %w", err)
|
||||
}
|
||||
}
|
||||
return primaryErr, errors.Join(reportErr, errorLogErr)
|
||||
}
|
||||
|
||||
func failPipelineCommand(stderr io.Writer, state *pipelineCommandState, writer DebugTerminalWriter, primaryErr error, persistenceErrs ...error) int {
|
||||
primaryErr, terminalErr := state.terminalize(writer, primaryErr)
|
||||
persistenceErrs = append(persistenceErrs, terminalErr)
|
||||
return writePipelineCommandFailure(stderr, state, primaryErr, errors.Join(persistenceErrs...))
|
||||
}
|
||||
|
||||
func writePipelineCommandFailure(stderr io.Writer, state *pipelineCommandState, primaryErr, persistenceErr error) int {
|
||||
fmt.Fprintf(stderr, "notarius: %v\n", primaryErr)
|
||||
if persistenceErr != nil {
|
||||
fmt.Fprintf(stderr, "notarius: %v\n", persistenceErr)
|
||||
}
|
||||
if state != nil && state.report.DebugPath != "" {
|
||||
fmt.Fprintf(stderr, "notarius: debug=%s\n", state.report.DebugPath)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
68
internal/cli/scriptorium_profiles.go
Normal file
68
internal/cli/scriptorium_profiles.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/scriptorium"
|
||||
)
|
||||
|
||||
const profileCheckPromptID = "notarius.profile.check"
|
||||
|
||||
var profileCheckPromptFS = fstest.MapFS{
|
||||
"prompts/profile-check.yaml": &fstest.MapFile{Data: []byte(`id: notarius.profile.check
|
||||
version: "1.0.0"
|
||||
default_profile: mistral-small-3
|
||||
inputs:
|
||||
- name: transcript
|
||||
required: true
|
||||
messages:
|
||||
- role: user
|
||||
content: "{{input \"transcript\"}}"
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
repair_attempts: 0
|
||||
`)},
|
||||
}
|
||||
|
||||
func validateExplicitScriptoriumProfiles(ctx context.Context, cfg config.Config, profileIDs []string) error {
|
||||
if len(profileIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
engine, err := newProfileValidationEngine(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Scriptorium profiles: %w", err)
|
||||
}
|
||||
for _, profileID := range profileIDs {
|
||||
if _, err := engine.Prepare(ctx, scriptorium.RunRequest{
|
||||
PromptID: profileCheckPromptID,
|
||||
ProfileID: profileID,
|
||||
Inputs: map[string]scriptorium.ArtifactRef{
|
||||
"transcript": scriptorium.Inline("profile check"),
|
||||
},
|
||||
}); err != nil {
|
||||
if errors.Is(err, scriptorium.ErrProfileNotFound) {
|
||||
return fmt.Errorf("Scriptorium profile %q is not configured", profileID)
|
||||
}
|
||||
return fmt.Errorf("validate Scriptorium profile %q: %w", profileID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newProfileValidationEngine(cfg config.Config) (*scriptorium.Engine, error) {
|
||||
opts := []scriptorium.Option{
|
||||
scriptorium.WithPromptFS(profileCheckPromptFS, "prompts"),
|
||||
}
|
||||
if cfg.Scriptorium.ProfileFile != "" {
|
||||
opts = append(opts, scriptorium.WithProfileFile(cfg.Scriptorium.ProfileFile))
|
||||
}
|
||||
return scriptorium.NewEngine(scriptorium.Config{
|
||||
PromptDir: "unused",
|
||||
ProfileDir: cfg.Scriptorium.ProfileDir,
|
||||
}, opts...)
|
||||
}
|
||||
490
internal/cli/spell_catalog_identity_contract_test.go
Normal file
490
internal/cli/spell_catalog_identity_contract_test.go
Normal file
@@ -0,0 +1,490 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/checkpoint"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
|
||||
)
|
||||
|
||||
func TestSpellCatalogBytesAffectCheckpointIdentityButNotSemanticDigest(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
configPath := writeProductionSpellCatalogContractConfig(t)
|
||||
effective, err := loadMaintainedExample(t, configPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve production configuration: %v", err)
|
||||
}
|
||||
overlayPath := filepath.Join(t.TempDir(), "catalog.json")
|
||||
resolved := effective.ResolvedPipeline
|
||||
bindings := resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings
|
||||
catalogBindingIndex := -1
|
||||
for index, binding := range bindings {
|
||||
if binding.SlotName == spellcatalog.SpellCatalogReferenceSlot {
|
||||
catalogBindingIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if catalogBindingIndex < 0 {
|
||||
t.Fatalf("spell catalog bindings = %#v, want catalog binding", bindings)
|
||||
}
|
||||
resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings[catalogBindingIndex].Source = overlayPath
|
||||
normalizeBindings := resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings
|
||||
normalizeCatalogBindingIndex := -1
|
||||
for index, binding := range normalizeBindings {
|
||||
if binding.SlotName == spellcatalog.SpellCatalogReferenceSlot {
|
||||
normalizeCatalogBindingIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if normalizeCatalogBindingIndex < 0 {
|
||||
t.Fatalf("normalize spell catalog bindings = %#v, want catalog binding", normalizeBindings)
|
||||
}
|
||||
resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings[normalizeCatalogBindingIndex].Source = overlayPath
|
||||
|
||||
if err := os.WriteFile(overlayPath, []byte(reorderedOverlayA), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
materializedA, _, err := pipeline.MaterializeReferences(resolved, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{ConfigPath: configPath, WorkingDir: filepath.Dir(configPath)})
|
||||
if err != nil {
|
||||
t.Fatalf("materialize first catalog: %v", err)
|
||||
}
|
||||
identityA := catalogCheckpointIdentity(t, materializedA)
|
||||
metadataA := catalogExtractorMetadata(t, materializedA)
|
||||
normalizerMetadataA := catalogNormalizerMetadata(t, materializedA)
|
||||
referenceA := catalogReference(t, materializedA)
|
||||
|
||||
if err := os.WriteFile(overlayPath, []byte(reorderedOverlayB), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
materializedB, _, err := pipeline.MaterializeReferences(resolved, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{ConfigPath: configPath, WorkingDir: filepath.Dir(configPath)})
|
||||
if err != nil {
|
||||
t.Fatalf("materialize reordered catalog: %v", err)
|
||||
}
|
||||
identityB := catalogCheckpointIdentity(t, materializedB)
|
||||
metadataB := catalogExtractorMetadata(t, materializedB)
|
||||
normalizerMetadataB := catalogNormalizerMetadata(t, materializedB)
|
||||
referenceB := catalogReference(t, materializedB)
|
||||
|
||||
if identityA.Digest == identityB.Digest {
|
||||
t.Fatalf("checkpoint identity digest = %q for both raw catalog files, want invalidation", identityA.Digest)
|
||||
}
|
||||
if referenceA.Digest == referenceB.Digest || referenceA.OriginURI != referenceB.OriginURI {
|
||||
t.Fatalf("catalog reference provenance changed from %#v to %#v, want same origin and different raw digest", referenceA, referenceB)
|
||||
}
|
||||
digestA, ok := metadataA["catalog_digest"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("first extractor catalog metadata = %#v, want digest", metadataA)
|
||||
}
|
||||
digestB, ok := metadataB["catalog_digest"].(string)
|
||||
if !ok || digestA != digestB {
|
||||
t.Fatalf("extractor catalog digests = %q and %q, want same semantic digest", digestA, digestB)
|
||||
}
|
||||
if got, want := metadataA["catalog_overlay_ids"], []string{"campaign.a", "campaign.b"}; !reflect.DeepEqual(got, want) || !reflect.DeepEqual(metadataB["catalog_overlay_ids"], want) {
|
||||
t.Fatalf("extractor overlay IDs = %#v and %#v, want %#v", got, metadataB["catalog_overlay_ids"], want)
|
||||
}
|
||||
normalizerDigestA, ok := normalizerMetadataA["catalog_digest"].(string)
|
||||
normalizerDigestB, okB := normalizerMetadataB["catalog_digest"].(string)
|
||||
if !ok || !okB || normalizerDigestA != digestA || normalizerDigestB != digestB {
|
||||
t.Fatalf("normalizer catalog digests = %#v and %#v, want extractor semantic digests %q and %q", normalizerMetadataA["catalog_digest"], normalizerMetadataB["catalog_digest"], digestA, digestB)
|
||||
}
|
||||
if got, want := normalizerMetadataA["catalog_overlay_ids"], []string{"campaign.a", "campaign.b"}; !reflect.DeepEqual(got, want) || !reflect.DeepEqual(normalizerMetadataB["catalog_overlay_ids"], want) {
|
||||
t.Fatalf("normalizer overlay IDs = %#v and %#v, want %#v", got, normalizerMetadataB["catalog_overlay_ids"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredSpellCatalogBindingChangesResolvedPipelineIdentity(t *testing.T) {
|
||||
base := productionSpellCatalogContractConfig(t)
|
||||
changed := strings.Replace(base, repositoryPath("examples", "dnd-spell-catalog.json"), filepath.Join(t.TempDir(), "alternate-spell-catalog.json"), 1)
|
||||
if changed == base {
|
||||
t.Fatal("production configuration did not contain the maintained catalog binding")
|
||||
}
|
||||
root := t.TempDir()
|
||||
firstPath := filepath.Join(root, "first.yml")
|
||||
secondPath := filepath.Join(root, "second.yml")
|
||||
if err := os.WriteFile(firstPath, []byte(base), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(secondPath, []byte(changed), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
components := productionTestComponents(t)
|
||||
first, err := loadMaintainedExample(t, firstPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve first configuration: %v", err)
|
||||
}
|
||||
second, err := loadMaintainedExample(t, secondPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve changed configuration: %v", err)
|
||||
}
|
||||
if first.ResolvedPipeline.Digest == second.ResolvedPipeline.Digest {
|
||||
t.Fatalf("resolved pipeline digest = %q for different catalog bindings, want change", first.ResolvedPipeline.Digest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticSpellCatalogFingerprintChangesCheckpointIdentityWithoutReferenceChange(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
configPath := writeProductionSpellCatalogContractConfig(t)
|
||||
effective, err := loadMaintainedExample(t, configPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{ConfigPath: configPath, WorkingDir: filepath.Dir(configPath)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepared, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fingerprints := prepared.CheckpointFingerprints()
|
||||
wantNames := map[string]struct{}{
|
||||
"extract:spells:" + spells.Key + ":effective_catalog": {},
|
||||
"extract:spells:" + spells.Key + ":validator:2:extract/dnd/spells/catalog:effective_catalog": {},
|
||||
"normalize:spells:" + spellnormalize.Key + ":effective_catalog": {},
|
||||
"normalize:spells:" + spellnormalize.Key + ":validator:2:extract/dnd/spells/catalog:effective_catalog": {},
|
||||
}
|
||||
seen := make(map[string]string, len(fingerprints))
|
||||
for _, fingerprint := range fingerprints {
|
||||
if _, ok := wantNames[fingerprint.Name]; ok {
|
||||
seen[fingerprint.Name] = fingerprint.Value
|
||||
}
|
||||
}
|
||||
if len(seen) != len(wantNames) {
|
||||
t.Fatalf("prepared fingerprints = %#v, want scoped extractor and normalize catalog identities", fingerprints)
|
||||
}
|
||||
var catalogDigest string
|
||||
for name, value := range seen {
|
||||
if catalogDigest == "" {
|
||||
catalogDigest = value
|
||||
} else if value != catalogDigest {
|
||||
t.Fatalf("prepared fingerprint %q = %q, want shared semantic catalog digest %q", name, value, catalogDigest)
|
||||
}
|
||||
}
|
||||
|
||||
identityFor := func(values []pipeline.CheckpointFingerprint) checkpoint.Identity {
|
||||
identity, identityErr := checkpoint.NewIdentity(checkpoint.IdentityInput{
|
||||
Pipeline: materialized,
|
||||
InputKey: materialized.Input.Module,
|
||||
RawInputDigest: "sha256:unchanged-input",
|
||||
References: pipeline.ReferenceProvenance(materialized),
|
||||
ProvenanceFingerprints: checkpointIdentityFingerprints(values),
|
||||
})
|
||||
if identityErr != nil {
|
||||
t.Fatal(identityErr)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
first := identityFor(fingerprints)
|
||||
changed := replaceCheckpointFingerprintValue(t, fingerprints, normalizeSpellCatalogFingerprintName(), "sha256:changed-effective-catalog")
|
||||
assertOnlyCheckpointFingerprintChanged(t, fingerprints, changed, normalizeSpellCatalogFingerprintName())
|
||||
second := identityFor(changed)
|
||||
if first.Digest == second.Digest || reflect.DeepEqual(first.ReferenceDigests, nil) || !reflect.DeepEqual(first.ReferenceDigests, second.ReferenceDigests) {
|
||||
t.Fatalf("identities = %#v / %#v, want semantic invalidation with unchanged reference provenance", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangedSemanticSpellCatalogFingerprintCannotResumeRecordedCheckpoint(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
configPath := writeProductionSpellCatalogContractConfig(t)
|
||||
effective, err := loadMaintainedExample(t, configPath).Resolve(resolveInputForMaintainedExample(components, "dnd-session"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{ConfigPath: configPath, WorkingDir: filepath.Dir(configPath)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepared, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fingerprints := prepared.CheckpointFingerprints()
|
||||
settings := config.CheckpointCacheConfig{Enabled: true, Directory: t.TempDir()}
|
||||
recorder, _, err := checkpointHandlersForRun(settings, Options{}, materialized, fingerprints, []byte("same input"), nil, nil, "", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := source.SourceDocument{ID: "source", Kind: "transcript", Format: "application/json"}
|
||||
doc.Units = []source.SourceUnit{{ID: 1, Kind: "turn", Text: "Aria casts Cure Wounds.", Ref: source.SourceRef{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}
|
||||
doc.Digest, err = source.DigestDocument(&doc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := recorder.SourceSucceeded(materialized.Input.Module, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normalizeDependencies := []pipeline.CheckpointFingerprint{{Name: "artifact[0]", Value: "sha256:merged-artifact"}}
|
||||
normalizeSchema := contracts.ArtifactSchema{ID: "notarius.dnd.spells", Name: "notarius_dnd_spells", Version: "v1"}
|
||||
normalizeArtifact := pipeline.CheckpointArtifact{
|
||||
LaneID: "spells", ModuleKey: spellnormalize.Key, SourceID: doc.ID,
|
||||
SchemaDigest: contracts.DigestArtifactSchema(normalizeSchema),
|
||||
Artifact: contracts.SerializedArtifact{
|
||||
Kind: dnd.SpellListKind, Schema: normalizeSchema, MediaType: "application/json", Content: []byte(`{"spell_casts":[]}`),
|
||||
},
|
||||
}
|
||||
if err := recorder.NormalizeSucceeded("spells", spellnormalize.Key, normalizeDependencies, normalizeArtifact, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, sameLoader, err := checkpointHandlersForRun(settings, Options{}, materialized, fingerprints, []byte("same input"), nil, nil, "", "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, decision := sameLoader.Source(materialized.Input.Module); !decision.Reused {
|
||||
t.Fatalf("same fingerprint decision = %#v, want reuse", decision)
|
||||
}
|
||||
if restored, decision := sameLoader.Normalize("spells", spellnormalize.Key, normalizeDependencies); !decision.Reused || string(restored.Output.Artifact.Content) != `{"spell_casts":[]}` {
|
||||
t.Fatalf("same normalize checkpoint = %#v, decision=%#v, want reuse", restored, decision)
|
||||
}
|
||||
changed := replaceCheckpointFingerprintValue(t, fingerprints, normalizeSpellCatalogFingerprintName(), "sha256:changed-effective-catalog")
|
||||
assertOnlyCheckpointFingerprintChanged(t, fingerprints, changed, normalizeSpellCatalogFingerprintName())
|
||||
_, changedLoader, err := checkpointHandlersForRun(settings, Options{}, materialized, changed, []byte("same input"), nil, nil, "", "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, decision := changedLoader.Source(materialized.Input.Module); decision.Reused {
|
||||
t.Fatalf("changed fingerprint decision = %#v, want cold miss", decision)
|
||||
}
|
||||
if _, decision := changedLoader.Normalize("spells", spellnormalize.Key, normalizeDependencies); decision.Reused {
|
||||
t.Fatalf("changed normalize fingerprint decision = %#v, want normalize checkpoint cold miss", decision)
|
||||
}
|
||||
changedMapping := replaceCheckpointFingerprintValue(t, fingerprints, extractSpellMappingFingerprintName(), "dnd.spells.extract_mapping.v3")
|
||||
assertOnlyCheckpointFingerprintChanged(t, fingerprints, changedMapping, extractSpellMappingFingerprintName())
|
||||
_, mappingLoader, err := checkpointHandlersForRun(settings, Options{}, materialized, changedMapping, []byte("same input"), nil, nil, "", "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, decision := mappingLoader.Source(materialized.Input.Module); decision.Reused {
|
||||
t.Fatalf("changed mapping policy decision = %#v, want cold miss", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSpellCatalogFingerprintName() string {
|
||||
return "normalize:spells:" + spellnormalize.Key + ":effective_catalog"
|
||||
}
|
||||
|
||||
func extractSpellMappingFingerprintName() string {
|
||||
return "extract:spells:" + spells.Key + ":mapping_policy"
|
||||
}
|
||||
|
||||
func replaceCheckpointFingerprintValue(t *testing.T, fingerprints []pipeline.CheckpointFingerprint, name, value string) []pipeline.CheckpointFingerprint {
|
||||
t.Helper()
|
||||
changed := append([]pipeline.CheckpointFingerprint(nil), fingerprints...)
|
||||
matches := 0
|
||||
for index := range changed {
|
||||
if changed[index].Name == name {
|
||||
changed[index].Value = value
|
||||
matches++
|
||||
}
|
||||
}
|
||||
if matches != 1 {
|
||||
t.Fatalf("checkpoint fingerprints = %#v, want exactly one fingerprint named %q", fingerprints, name)
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func assertOnlyCheckpointFingerprintChanged(t *testing.T, before, after []pipeline.CheckpointFingerprint, changedName string) {
|
||||
t.Helper()
|
||||
if len(before) != len(after) {
|
||||
t.Fatalf("fingerprint lengths = %d and %d, want equal", len(before), len(after))
|
||||
}
|
||||
changes := 0
|
||||
for index := range before {
|
||||
if before[index].Name != after[index].Name {
|
||||
t.Fatalf("fingerprint[%d] name changed from %q to %q", index, before[index].Name, after[index].Name)
|
||||
}
|
||||
if before[index].Value == after[index].Value {
|
||||
continue
|
||||
}
|
||||
changes++
|
||||
if before[index].Name != changedName {
|
||||
t.Fatalf("fingerprint %q changed unexpectedly", before[index].Name)
|
||||
}
|
||||
}
|
||||
if changes != 1 {
|
||||
t.Fatalf("fingerprints changed %d values, want exactly %q", changes, changedName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintainedProductionOverlayRunAlignsGroundingValidationAndProvenance(t *testing.T) {
|
||||
outputRoot := filepath.Join(t.TempDir(), "output")
|
||||
fake := &productionFakeLLMClient{spellResponse: productionSpellResponse("Aegis of Emberfall")}
|
||||
options := productionRunOptions(t, fake)
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions([]string{
|
||||
"run", "dnd-session",
|
||||
"--config", writeProductionSpellCatalogContractConfig(t),
|
||||
"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
|
||||
"--only", "spells", "--chunk_cache", "bypass", "--output-dir", outputRoot,
|
||||
}, &stdout, &stderr, options)
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
runRoot := filepath.Join(outputRoot, productionRunID)
|
||||
manifest := readProductionJSON[artifacts.RunManifest](t, filepath.Join(runRoot, "manifest.json"))
|
||||
if manifest.ValidationStatus != "approved" || len(manifest.References) == 0 || len(manifest.ArtifactLanes) != 1 {
|
||||
t.Fatalf("manifest = %#v, want approved overlay run with one lane and references", manifest)
|
||||
}
|
||||
lane := manifest.ArtifactLanes[0]
|
||||
extractorMetadata, ok := lane.Metadata["extractor"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("lane metadata = %#v, want extractor metadata", lane.Metadata)
|
||||
}
|
||||
if extractorMetadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || !strings.HasPrefix(stringValue(extractorMetadata["catalog_digest"]), "sha256:") {
|
||||
t.Fatalf("extractor catalog metadata = %#v, want base ID and semantic digest", extractorMetadata)
|
||||
}
|
||||
if got := stringValues(extractorMetadata["catalog_overlay_ids"]); !reflect.DeepEqual(got, []string{"notarius.example-campaign"}) {
|
||||
t.Fatalf("catalog overlay IDs = %#v, want maintained overlay", got)
|
||||
}
|
||||
normalizerMetadata, ok := lane.Metadata["normalizer"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("lane metadata = %#v, want normalizer metadata", lane.Metadata)
|
||||
}
|
||||
if normalizerMetadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || !strings.HasPrefix(stringValue(normalizerMetadata["catalog_digest"]), "sha256:") || !reflect.DeepEqual(stringValues(normalizerMetadata["catalog_overlay_ids"]), []string{"notarius.example-campaign"}) {
|
||||
t.Fatalf("normalizer catalog metadata = %#v, want base ID, semantic digest, and overlay IDs", normalizerMetadata)
|
||||
}
|
||||
if normalizerMetadata["catalog_digest"] != extractorMetadata["catalog_digest"] || !reflect.DeepEqual(stringValues(normalizerMetadata["catalog_overlay_ids"]), stringValues(extractorMetadata["catalog_overlay_ids"])) {
|
||||
t.Fatalf("extractor metadata = %#v, normalizer metadata = %#v, want shared catalog identity", extractorMetadata, normalizerMetadata)
|
||||
}
|
||||
|
||||
var catalogProvenances []artifacts.ReferenceProvenance
|
||||
for index := range manifest.References {
|
||||
reference := &manifest.References[index]
|
||||
if reference.SlotName == spellcatalog.SpellCatalogReferenceSlot {
|
||||
catalogProvenances = append(catalogProvenances, *reference)
|
||||
}
|
||||
}
|
||||
if len(catalogProvenances) != 2 {
|
||||
t.Fatalf("manifest references = %#v, want independently materialized extract and normalize catalog provenance", manifest.References)
|
||||
}
|
||||
overlayBytes := readRepositoryFile(t, "examples", "dnd-spell-catalog.json")
|
||||
for _, catalogProvenance := range catalogProvenances {
|
||||
if catalogProvenance.Stage != "extract" && catalogProvenance.Stage != "normalize" {
|
||||
t.Fatalf("catalog provenance = %#v, want extract or normalize scope", catalogProvenance)
|
||||
}
|
||||
if catalogProvenance.LaneID != "spells" || catalogProvenance.OriginType != "file" || catalogProvenance.MediaType != "application/json" || catalogProvenance.SizeBytes != int64(len(overlayBytes)) || catalogProvenance.Digest != digestBytes(overlayBytes) || !strings.Contains(catalogProvenance.OriginURI, "dnd-spell-catalog.json") {
|
||||
t.Fatalf("catalog provenance = %#v, want raw overlay provenance in both scopes", catalogProvenance)
|
||||
}
|
||||
}
|
||||
manifestBytes, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, leaked := range []string{"Aegis of Emberfall", "Emberfall Aegis", "Notarius example campaign spell names"} {
|
||||
if strings.Contains(string(manifestBytes), leaked) {
|
||||
t.Fatalf("manifest leaked overlay content %q", leaked)
|
||||
}
|
||||
}
|
||||
|
||||
requests := fake.requestsFor(spells.PromptID)
|
||||
if len(requests) != 1 {
|
||||
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)
|
||||
}
|
||||
artifact := readProductionJSON[dnd.SpellList](t, filepath.Join(runRoot, "lanes", "spells.json"))
|
||||
if len(artifact.SpellCasts) != 1 || artifact.SpellCasts[0].Spell != "Aegis of Emberfall" {
|
||||
t.Fatalf("artifact = %#v, want accepted overlay-only canonical spell", artifact)
|
||||
}
|
||||
rejected := readProductionJSON[struct {
|
||||
Rejected []json.RawMessage `json:"rejected"`
|
||||
}](t, filepath.Join(runRoot, "rejected.json"))
|
||||
if len(rejected.Rejected) != 0 {
|
||||
t.Fatalf("rejected = %#v, want no rejected output", rejected.Rejected)
|
||||
}
|
||||
}
|
||||
|
||||
func catalogCheckpointIdentity(t *testing.T, resolved pipeline.ResolvedPipeline) checkpoint.Identity {
|
||||
t.Helper()
|
||||
identity, err := checkpoint.NewIdentity(checkpoint.IdentityInput{
|
||||
Pipeline: resolved,
|
||||
InputKey: resolved.Input.Module,
|
||||
RawInputDigest: "sha256:catalog-test-input",
|
||||
References: pipeline.ReferenceProvenance(resolved),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create checkpoint identity: %v", err)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
func catalogExtractorMetadata(t *testing.T, resolved pipeline.ResolvedPipeline) map[string]any {
|
||||
t.Helper()
|
||||
lane := resolved.Steps[0].ArtifactLanes[0]
|
||||
extractor, err := spells.New(&productionFakeLLMClient{}, spells.Options{}, lane.ExtractReferences.ReferenceSet)
|
||||
if err != nil {
|
||||
t.Fatalf("construct extractor: %v", err)
|
||||
}
|
||||
return extractor.ManifestMetadata()
|
||||
}
|
||||
|
||||
func catalogNormalizerMetadata(t *testing.T, resolved pipeline.ResolvedPipeline) map[string]any {
|
||||
t.Helper()
|
||||
lane := resolved.Steps[0].ArtifactLanes[0]
|
||||
normalizer, err := spellnormalize.New(spellnormalize.Options{}, lane.NormalizeReferences.ReferenceSet)
|
||||
if err != nil {
|
||||
t.Fatalf("construct normalizer: %v", err)
|
||||
}
|
||||
return normalizer.ManifestMetadata()
|
||||
}
|
||||
|
||||
func catalogReference(t *testing.T, resolved pipeline.ResolvedPipeline) artifacts.ReferenceProvenance {
|
||||
t.Helper()
|
||||
for _, reference := range pipeline.ReferenceProvenance(resolved) {
|
||||
if reference.SlotName == spellcatalog.SpellCatalogReferenceSlot && reference.Stage == "extract" && reference.LaneID == "spells" {
|
||||
return reference
|
||||
}
|
||||
}
|
||||
t.Fatalf("resolved references = %#v, want spell catalog provenance", pipeline.ReferenceProvenance(resolved))
|
||||
return artifacts.ReferenceProvenance{}
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
result, _ := value.(string)
|
||||
return result
|
||||
}
|
||||
|
||||
func stringValues(value any) []string {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return nil
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func digestBytes(value []byte) string {
|
||||
sum := sha256.Sum256(value)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
const reorderedOverlayA = `{
|
||||
"schema_version": "notarius.dnd.spell-catalog-overlay.v1",
|
||||
"catalogs": [
|
||||
{"id":"campaign.a","ruleset":"dnd-5e-2014","source":{"title":"Campaign A"},"spells":[{"name":"Aegis of Emberfall","aliases":["Emberfall Aegis"]}]},
|
||||
{"id":"campaign.b","ruleset":"dnd-5e-2014","source":{"title":"Campaign B"},"spells":[{"name":"Cinder Veil","aliases":["Veil of Cinder","Cinder Shroud"]}]}
|
||||
]
|
||||
}`
|
||||
|
||||
const reorderedOverlayB = `{"catalogs":[{"spells":[{"aliases":["Cinder Shroud","Veil of Cinder"],"name":"Cinder Veil"}],"source":{"title":"Campaign B"},"ruleset":"dnd-5e-2014","id":"campaign.b"},{"spells":[{"aliases":["Emberfall Aegis"],"name":"Aegis of Emberfall"}],"source":{"title":"Campaign A"},"ruleset":"dnd-5e-2014","id":"campaign.a"}],"schema_version":"notarius.dnd.spell-catalog-overlay.v1"}`
|
||||
160
internal/cli/spell_catalog_retry_contract_test.go
Normal file
160
internal/cli/spell_catalog_retry_contract_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"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"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
)
|
||||
|
||||
func TestProductionSpellCatalogValidationRetries(t *testing.T) {
|
||||
const retries = 2
|
||||
tests := []struct {
|
||||
name string
|
||||
responses []string
|
||||
wantCalls int
|
||||
wantRejected bool
|
||||
wantSpell string
|
||||
wantWarningCode string
|
||||
}{
|
||||
{
|
||||
name: "unknown spell remains rejected after exhaustion",
|
||||
responses: []string{
|
||||
productionSpellResponse("Unknown Spell"),
|
||||
productionSpellResponse("Unknown Spell"),
|
||||
productionSpellResponse("Unknown Spell"),
|
||||
},
|
||||
wantCalls: retries + 1,
|
||||
wantRejected: true,
|
||||
},
|
||||
{
|
||||
name: "overlay spell becomes valid on retry",
|
||||
responses: []string{
|
||||
productionSpellResponse("Unknown Spell"),
|
||||
productionSpellResponse("Aegis of Emberfall"),
|
||||
},
|
||||
wantCalls: 2,
|
||||
wantSpell: "Aegis of Emberfall",
|
||||
wantWarningCode: "spell_not_near_source",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
configPath := writeProductionSpellCatalogContractConfig(t)
|
||||
cfg := loadMaintainedExample(t, configPath)
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalogFromRegistries(components.registries)})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve production configuration: %v", err)
|
||||
}
|
||||
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalogFromRegistries(components.registries), pipeline.ReferenceMaterializationOptions{
|
||||
ConfigPath: configPath,
|
||||
WorkingDir: filepath.Dir(configPath),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("materialize production references: %v", err)
|
||||
}
|
||||
materialized.Steps[0].ArtifactLanes[0].Extract.Retries = retries
|
||||
|
||||
llmClient := &catalogRetryLLMClient{responses: tt.responses}
|
||||
prepared, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: llmClient})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare production pipeline: %v", err)
|
||||
}
|
||||
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
|
||||
Prepared: prepared,
|
||||
RawInput: readRepositoryFile(t, "examples", "seriatim-minimal-transcript.json"),
|
||||
ChunkCacheMode: pipeline.ChunkCacheBypass,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v, want nil", err)
|
||||
}
|
||||
if calls := llmClient.CallCount(); calls > retries+1 || calls != tt.wantCalls {
|
||||
t.Fatalf("LLM calls = %d, want %d and no more than %d", calls, tt.wantCalls, retries+1)
|
||||
}
|
||||
|
||||
if tt.wantRejected {
|
||||
if len(output.Rejected) != 1 || len(output.NormalizeOutputs) != 0 {
|
||||
t.Fatalf("rejected = %#v normalized = %#v, want one nonfatal rejection and no merge output", output.Rejected, output.NormalizeOutputs)
|
||||
}
|
||||
rejection := output.Rejected[0]
|
||||
if rejection.ReasonCode != "unknown_spell" || rejection.AttemptCount != retries+1 {
|
||||
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)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 {
|
||||
t.Fatalf("rejected = %#v normalized = %#v, want only accepted output", output.Rejected, output.NormalizeOutputs)
|
||||
}
|
||||
var value dnd.SpellList
|
||||
if err := json.Unmarshal(output.NormalizeOutputs[0].Artifact.Content, &value); err != nil {
|
||||
t.Fatalf("decode normalized spell list: %v", err)
|
||||
}
|
||||
if len(value.SpellCasts) != 1 || value.SpellCasts[0].Spell != tt.wantSpell {
|
||||
t.Fatalf("normalized spell list = %#v, want accepted overlay spell", value)
|
||||
}
|
||||
if len(output.Warnings) != 2 || output.Warnings[0].ReasonCode != tt.wantWarningCode || output.Warnings[1].ReasonCode != tt.wantWarningCode {
|
||||
t.Fatalf("warnings = %#v, want accepted-attempt warnings from extract and normalize validation", output.Warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type catalogRetryLLMClient struct {
|
||||
mu sync.Mutex
|
||||
responses []string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (client *catalogRetryLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, err
|
||||
}
|
||||
if req.PromptID != spells.PromptID {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)
|
||||
}
|
||||
client.mu.Lock()
|
||||
index := client.calls
|
||||
client.calls++
|
||||
client.mu.Unlock()
|
||||
if index >= len(client.responses) {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("missing fake response %d", index)
|
||||
}
|
||||
content := []byte(client.responses[index])
|
||||
if err := json.Unmarshal(content, out); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate fake structured target: %w", err)
|
||||
}
|
||||
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "deterministic", ProfileID: req.ProfileID}, nil
|
||||
}
|
||||
|
||||
func (client *catalogRetryLLMClient) CallCount() int {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
return client.calls
|
||||
}
|
||||
|
||||
func productionSpellResponse(name string) string {
|
||||
content, err := json.Marshal(dnd.SpellList{SpellCasts: []dnd.SpellCast{{
|
||||
Caster: "Aria",
|
||||
Spell: name,
|
||||
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(content)
|
||||
}
|
||||
988
internal/cli/state_hardening_test.go
Normal file
988
internal/cli/state_hardening_test.go
Normal file
@@ -0,0 +1,988 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkplan"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
frameworkdebug "gitea.maximumdirect.net/eric/notarius/internal/framework/debug"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
const stateTestDigest = "sha256:e511d8906649b78eb639b11215fa57a9652a1a64f4aefa3ed68320dbda46f439"
|
||||
|
||||
func TestRunStateSurfaceMatrix(t *testing.T) {
|
||||
for _, debug := range []bool{false, true} {
|
||||
for _, resume := range []bool{false, true} {
|
||||
for _, mode := range []string{"auto", "bypass", "refresh"} {
|
||||
name := fmt.Sprintf("debug=%t/resume=%t/cache=%s", debug, resume, mode)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
harness := newStateTestHarness()
|
||||
opts := harness.options()
|
||||
var storeRoots []string
|
||||
opts.ChunkPlanStoreFactory = func(root string) (pipeline.ChunkPlanStore, error) {
|
||||
storeRoots = append(storeRoots, root)
|
||||
return chunkplan.NewFilesystemStore(root)
|
||||
}
|
||||
result := runStateTest(t, roots, opts, debug, resume, mode)
|
||||
if result.code != 0 {
|
||||
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
|
||||
}
|
||||
assertStateTestOutput(t, roots.output)
|
||||
if mode == "bypass" {
|
||||
assertAbsent(t, roots.plans)
|
||||
if len(storeRoots) != 0 {
|
||||
t.Fatalf("chunk plan store roots = %v, want none", storeRoots)
|
||||
}
|
||||
} else {
|
||||
assertFile(t, filepath.Join(roots.plans, strings.TrimPrefix(stateTestDigest, "sha256:"), "plan.json"))
|
||||
if len(storeRoots) != 1 || storeRoots[0] != roots.plans {
|
||||
t.Fatalf("chunk plan store roots = %v, want [%q]", storeRoots, roots.plans)
|
||||
}
|
||||
}
|
||||
assertAnyFile(t, roots.checkpoints)
|
||||
assertRestrictedTree(t, roots.checkpoints)
|
||||
if debug {
|
||||
bundle := onlyChildDir(t, roots.debug)
|
||||
assertFile(t, filepath.Join(bundle, "summary", "invocation.json"))
|
||||
assertAnyFile(t, filepath.Join(bundle, "trace"))
|
||||
assertRestrictedTree(t, roots.debug)
|
||||
} else {
|
||||
assertAbsent(t, roots.debug)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunKeepsStateRootsIndependentAndReusesSelectedCheckpointRoot(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
harness := newStateTestHarness()
|
||||
first := runStateTest(t, roots, harness.options(), true, false, "auto")
|
||||
if first.code != 0 {
|
||||
t.Fatalf("first run code=%d stderr=%q", first.code, first.stderr)
|
||||
}
|
||||
planPath := filepath.Join(roots.plans, strings.TrimPrefix(stateTestDigest, "sha256:"), "plan.json")
|
||||
initialPlan, err := os.ReadFile(planPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstBundle := onlyChildDir(t, roots.debug)
|
||||
|
||||
second := runStateTest(t, roots, harness.options(), false, false, "auto")
|
||||
if second.code != 0 {
|
||||
t.Fatalf("second run code=%d stderr=%q", second.code, second.stderr)
|
||||
}
|
||||
if harness.chunkCalls != 1 {
|
||||
t.Fatalf("chunk calls after debug toggle = %d, want 1", harness.chunkCalls)
|
||||
}
|
||||
if harness.extractCalls != 2 {
|
||||
t.Fatalf("extract calls after two recording-only runs = %d, want 2", harness.extractCalls)
|
||||
}
|
||||
if got, err := os.ReadFile(planPath); err != nil || !bytes.Equal(got, initialPlan) {
|
||||
t.Fatalf("chunk plan changed after debug toggle: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(firstBundle); err != nil {
|
||||
t.Fatalf("initial debug bundle was removed: %v", err)
|
||||
}
|
||||
|
||||
checkpointRoot := roots.checkpoints
|
||||
extractCallsBeforeResume := harness.extractCalls
|
||||
seed := runStateTest(t, roots, harness.options(), false, true, "auto")
|
||||
if seed.code != 0 {
|
||||
t.Fatalf("checkpoint seed code=%d stderr=%q", seed.code, seed.stderr)
|
||||
}
|
||||
if harness.extractCalls != extractCallsBeforeResume {
|
||||
t.Fatalf("extract calls after reusing recording-only checkpoint = %d, want %d", harness.extractCalls, extractCallsBeforeResume)
|
||||
}
|
||||
extractCalls := harness.extractCalls
|
||||
checkpointFiles := readTree(t, checkpointRoot)
|
||||
reused := runStateTest(t, roots, harness.options(), false, true, "auto")
|
||||
if reused.code != 0 {
|
||||
t.Fatalf("checkpoint reuse code=%d stderr=%q", reused.code, reused.stderr)
|
||||
}
|
||||
if harness.extractCalls != extractCalls {
|
||||
t.Fatalf("extract calls after checkpoint reuse = %d, want %d", harness.extractCalls, extractCalls)
|
||||
}
|
||||
if got := readTree(t, checkpointRoot); !sameFiles(got, checkpointFiles) {
|
||||
t.Fatal("reused checkpoint was rewritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecomputesOnlyAfterExplicitChunkPlanRemoval(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
harness := newStateTestHarness()
|
||||
first := runStateTest(t, roots, harness.options(), true, false, "auto")
|
||||
if first.code != 0 {
|
||||
t.Fatalf("first run code=%d stderr=%q", first.code, first.stderr)
|
||||
}
|
||||
firstOutput := onlyChildDir(t, roots.output)
|
||||
firstBundle := onlyChildDir(t, roots.debug)
|
||||
entry := filepath.Join(roots.plans, strings.TrimPrefix(stateTestDigest, "sha256:"))
|
||||
if err := os.RemoveAll(entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := runStateTest(t, roots, harness.options(), false, false, "auto")
|
||||
if second.code != 0 {
|
||||
t.Fatalf("second run code=%d stderr=%q", second.code, second.stderr)
|
||||
}
|
||||
if harness.chunkCalls != 2 {
|
||||
t.Fatalf("chunk calls = %d, want 2 after removing exact cache entry", harness.chunkCalls)
|
||||
}
|
||||
assertFile(t, filepath.Join(firstOutput, "result.json"))
|
||||
assertFile(t, filepath.Join(firstBundle, "summary", "run-report.json"))
|
||||
}
|
||||
|
||||
func TestRunRetainsDebugBundlesAcrossFailures(t *testing.T) {
|
||||
t.Run("configuration failure precedes allocation", func(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "debug")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", filepath.Join(t.TempDir(), "missing.yml"), "--input", "missing", "--debug", "--debug-dir", root}, &stdout, &stderr, newStateTestHarness().options())
|
||||
if code != 1 || !strings.Contains(stderr.String(), "config file") {
|
||||
t.Fatalf("code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
assertAbsent(t, root)
|
||||
})
|
||||
|
||||
for _, failure := range []struct {
|
||||
name string
|
||||
expected string
|
||||
setup func(*testing.T, stateTestRoots, *stateTestHarness) Options
|
||||
}{
|
||||
{"resolution", "pipeline \"missing\"", func(t *testing.T, roots stateTestRoots, h *stateTestHarness) Options { return h.options() }},
|
||||
{"pipeline", "synthetic extraction failure", func(t *testing.T, roots stateTestRoots, h *stateTestHarness) Options {
|
||||
h.extractErr = errors.New("synthetic extraction failure")
|
||||
return h.options()
|
||||
}},
|
||||
{"output", "create output parent", func(t *testing.T, roots stateTestRoots, h *stateTestHarness) Options {
|
||||
if err := os.WriteFile(roots.output, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h.options()
|
||||
}},
|
||||
{"summary", "write debug invocation metadata", func(t *testing.T, roots stateTestRoots, h *stateTestHarness) Options {
|
||||
opts := h.options()
|
||||
opts.DebugRecorderFactory = func(traceRoot string) (pipeline.DebugRecorder, error) {
|
||||
if err := os.RemoveAll(filepath.Join(filepath.Dir(traceRoot), "summary")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(traceRoot), "summary"), []byte("blocked"), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return frameworkdebug.NewFilesystemRecorder(traceRoot)
|
||||
}
|
||||
return opts
|
||||
}},
|
||||
{"trace", "trace unavailable", func(t *testing.T, roots stateTestRoots, h *stateTestHarness) Options {
|
||||
opts := h.options()
|
||||
opts.DebugRecorderFactory = func(string) (pipeline.DebugRecorder, error) { return failingDebugRecorder{}, nil }
|
||||
return opts
|
||||
}},
|
||||
} {
|
||||
t.Run(failure.name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
harness := newStateTestHarness()
|
||||
opts := failure.setup(t, roots, harness)
|
||||
failureStderr := ""
|
||||
if failure.name == "resolution" {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "missing", "--config", roots.config, "--input", roots.input, "--debug"}, &stdout, &stderr, opts)
|
||||
if code != 1 {
|
||||
t.Fatalf("code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
failureStderr = stderr.String()
|
||||
} else {
|
||||
result := runStateTest(t, roots, opts, true, false, "bypass")
|
||||
if result.code != 1 {
|
||||
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
|
||||
}
|
||||
failureStderr = result.stderr
|
||||
}
|
||||
if !strings.Contains(failureStderr, failure.expected) || !strings.Contains(failureStderr, "debug=") {
|
||||
t.Fatalf("stderr=%q, want %q and debug path", failureStderr, failure.expected)
|
||||
}
|
||||
bundle := onlyChildDir(t, roots.debug)
|
||||
if !strings.Contains(readAllFiles(t, bundle), "synthetic") && failure.name == "pipeline" {
|
||||
t.Fatal("pipeline failure was not retained in debug bundle")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDebugArtifactsRedactSecretsButRetainApplicationData(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
t.Setenv("STATE_TEST_UNRELATED_ENV", "HOST_ONLY_SENTINEL")
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(roots.input), "unrelated.txt"), []byte("HOST_ONLY_FILE_SENTINEL"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
harness := newStateTestHarness()
|
||||
result := runStateTest(t, roots, harness.options(), true, false, "bypass")
|
||||
if result.code != 0 {
|
||||
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
|
||||
}
|
||||
bundle := onlyChildDir(t, roots.debug)
|
||||
summary := readAllFiles(t, filepath.Join(bundle, "summary"))
|
||||
trace := readAllFiles(t, filepath.Join(bundle, "trace"))
|
||||
for _, forbidden := range []string{"sk-secretvalue", "Bearer secretvalue", "HOST_ONLY_SENTINEL", "HOST_ONLY_FILE_SENTINEL"} {
|
||||
if strings.Contains(summary, forbidden) || strings.Contains(trace, forbidden) {
|
||||
t.Fatalf("debug bundle contains %q", forbidden)
|
||||
}
|
||||
}
|
||||
if strings.Contains(summary, "application content") {
|
||||
t.Fatal("summary contains raw application input")
|
||||
}
|
||||
if !strings.Contains(trace, "application content") {
|
||||
t.Fatal("trace does not retain expected application input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRedactsSensitiveModuleOptionsFromConfigAndPipelineSummaries(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
data, err := os.ReadFile(roots.config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configText := replaceRequiredOnce(t, string(data), " input: test/input\n", ` input:
|
||||
module: test/input
|
||||
options:
|
||||
api_key: CONFIG_SUMMARY_SECRET_SENTINEL
|
||||
safe: SAFE_OPTION_SENTINEL
|
||||
nested:
|
||||
- - password: PIPELINE_SUMMARY_SECRET_SENTINEL
|
||||
neighbor: SAFE_NESTED_OPTION_SENTINEL
|
||||
`)
|
||||
if err := os.WriteFile(roots.config, []byte(configText), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result := runStateTest(t, roots, newStateTestHarness().options(), true, false, "bypass")
|
||||
if result.code != 0 {
|
||||
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
|
||||
}
|
||||
summaryRoot := filepath.Join(onlyChildDir(t, roots.debug), "summary")
|
||||
for _, name := range []string{"effective-config.json", "resolved-pipeline.json"} {
|
||||
contents, err := os.ReadFile(filepath.Join(summaryRoot, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(contents)
|
||||
for _, secret := range []string{"CONFIG_SUMMARY_SECRET_SENTINEL", "PIPELINE_SUMMARY_SECRET_SENTINEL"} {
|
||||
if strings.Contains(text, secret) {
|
||||
t.Fatalf("%s contains %q: %s", name, secret, text)
|
||||
}
|
||||
}
|
||||
for _, retained := range []string{"[REDACTED]", "SAFE_OPTION_SENTINEL", "SAFE_NESTED_OPTION_SENTINEL"} {
|
||||
if !strings.Contains(text, retained) {
|
||||
t.Fatalf("%s does not contain %q: %s", name, retained, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUsesOneInjectedIdentityForDebugOutputAndManifest(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
harness := newStateTestHarness()
|
||||
opts := harness.options()
|
||||
const runID = "run-1000000000-11111111111111111111111111111111"
|
||||
opts.RunIDGenerator = func(time.Time) (string, error) { return runID, nil }
|
||||
|
||||
result := runStateTest(t, roots, opts, true, false, "bypass")
|
||||
if result.code != 0 {
|
||||
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
|
||||
}
|
||||
outputPath := filepath.Join(roots.output, runID)
|
||||
debugPath := filepath.Join(roots.debug, runID)
|
||||
assertFile(t, filepath.Join(outputPath, "result.json"))
|
||||
assertFile(t, filepath.Join(debugPath, "summary", "run-manifest.json"))
|
||||
if !strings.Contains(result.stdout, "output="+outputPath) || !strings.Contains(result.stdout, "debug="+debugPath) {
|
||||
t.Fatalf("stdout=%q, want shared run identity", result.stdout)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(debugPath, "summary", "run-manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest artifacts.RunManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if manifest.RunID != runID {
|
||||
t.Fatalf("manifest run ID = %q, want %q", manifest.RunID, runID)
|
||||
}
|
||||
wantStartedAt := time.Unix(1, 0).UTC()
|
||||
if manifest.StartedAt == nil || !manifest.StartedAt.Equal(wantStartedAt) {
|
||||
t.Fatalf("manifest started at = %v, want %v", manifest.StartedAt, wantStartedAt)
|
||||
}
|
||||
var invocation debugbundle.Invocation
|
||||
readStateTestSummaryJSON(t, debugPath, "invocation.json", &invocation)
|
||||
if invocation.RunID != runID || !invocation.StartedAt.Equal(wantStartedAt) {
|
||||
t.Fatalf("debug invocation identity = %#v, want run %q at %v", invocation, runID, wantStartedAt)
|
||||
}
|
||||
report := readStateTestRunReport(t, debugPath)
|
||||
if !report.Succeeded || report.RunID != runID || report.PipelineID != "sample" || report.OutputPath != outputPath || report.DebugPath != debugPath || report.OutputCount != 1 || report.RejectedCount != 0 || report.WarningCount != 0 || report.ValidationStatus != "approved" {
|
||||
t.Fatalf("success report = %#v", report)
|
||||
}
|
||||
if !strings.Contains(result.stdout, "outputs=1 rejected=0") {
|
||||
t.Fatalf("stdout=%q, want report counts", result.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWritesTerminalArtifactsForResolutionPipelineAndOutputFailures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
pipelineID string
|
||||
wantError string
|
||||
wantOutputs int
|
||||
wantValidation string
|
||||
configureFailure func(*testing.T, stateTestRoots, *stateTestHarness)
|
||||
}{
|
||||
{name: "resolution", pipelineID: "missing", wantError: `pipeline "missing"`},
|
||||
{name: "pipeline", pipelineID: "sample", wantError: "synthetic extraction failure", wantValidation: "failed", configureFailure: func(_ *testing.T, _ stateTestRoots, h *stateTestHarness) {
|
||||
h.extractErr = errors.New("synthetic extraction failure")
|
||||
}},
|
||||
{name: "output", pipelineID: "sample", wantError: "create output parent", wantOutputs: 1, wantValidation: "approved", configureFailure: func(t *testing.T, roots stateTestRoots, _ *stateTestHarness) {
|
||||
if err := os.WriteFile(roots.output, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
harness := newStateTestHarness()
|
||||
if tc.configureFailure != nil {
|
||||
tc.configureFailure(t, roots, harness)
|
||||
}
|
||||
opts := harness.options()
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := []string{"run", tc.pipelineID, "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug"}
|
||||
code := RunWithOptions(args, &stdout, &stderr, opts)
|
||||
if code != 1 || !strings.Contains(stderr.String(), tc.wantError) {
|
||||
t.Fatalf("code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
bundlePath := onlyChildDir(t, roots.debug)
|
||||
runID := filepath.Base(bundlePath)
|
||||
report := readStateTestRunReport(t, bundlePath)
|
||||
if report.Succeeded || report.RunID != runID || report.PipelineID != tc.pipelineID || report.OutputPath != filepath.Join(roots.output, runID) || report.DebugPath != bundlePath || report.OutputCount != tc.wantOutputs || report.RejectedCount != 0 || report.WarningCount != 0 || report.ValidationStatus != tc.wantValidation {
|
||||
t.Fatalf("failure report = %#v", report)
|
||||
}
|
||||
errorLog, err := os.ReadFile(filepath.Join(bundlePath, "summary", "error.log"))
|
||||
if err != nil || !strings.Contains(string(errorLog), tc.wantError) {
|
||||
t.Fatalf("error log = %q, %v", errorLog, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetainsPartialPipelineOutcomeInFailureSummary(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
harness := newStateTestHarness()
|
||||
harness.chunkWarnings = []contracts.Warning{{Scope: "chunk", ReasonCode: "partial-warning", Message: "warning retained before failure"}}
|
||||
harness.extractErr = errors.New("synthetic partial pipeline failure")
|
||||
|
||||
result := runStateTest(t, roots, harness.options(), true, true, "bypass")
|
||||
if result.code != 1 {
|
||||
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
|
||||
}
|
||||
bundlePath := onlyChildDir(t, roots.debug)
|
||||
report := readStateTestRunReport(t, bundlePath)
|
||||
if report.Succeeded || report.OutputCount != 0 || report.RejectedCount != 0 || report.WarningCount != 1 || report.ValidationStatus != "failed" {
|
||||
t.Fatalf("partial failure report = %#v", report)
|
||||
}
|
||||
|
||||
var manifest artifacts.RunManifest
|
||||
readStateTestSummaryJSON(t, bundlePath, "run-manifest.json", &manifest)
|
||||
if manifest.RunID != report.RunID || manifest.PipelineID != "sample" || manifest.ValidationStatus != "failed" {
|
||||
t.Fatalf("partial manifest = %#v", manifest)
|
||||
}
|
||||
var warnings []contracts.Warning
|
||||
readStateTestSummaryJSON(t, bundlePath, "warnings.json", &warnings)
|
||||
if len(warnings) != 1 || warnings[0].ReasonCode != "partial-warning" {
|
||||
t.Fatalf("partial warnings = %#v", warnings)
|
||||
}
|
||||
var events []pipeline.CheckpointEvent
|
||||
readStateTestSummaryJSON(t, bundlePath, "checkpoint-events.json", &events)
|
||||
if len(events) == 0 || events[0].Stage != "source" {
|
||||
t.Fatalf("partial checkpoint events = %#v, want retained source decision", events)
|
||||
}
|
||||
var chunkPlan artifacts.ChunkPlanSummary
|
||||
readStateTestSummaryJSON(t, bundlePath, "chunk-plan.json", &chunkPlan)
|
||||
if chunkPlan.Mode != "bypass" || chunkPlan.ValidationStatus == "not_run" {
|
||||
t.Fatalf("partial chunk plan = %#v", chunkPlan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTerminalPersistenceFailuresDoNotRecurseOrHidePrimaryError(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
reportErr error
|
||||
errorLogErr error
|
||||
wantSecondary string
|
||||
}{
|
||||
{name: "run report", reportErr: errors.New("injected run report failure"), wantSecondary: "injected run report failure"},
|
||||
{name: "error log", errorLogErr: errors.New("injected error log failure"), wantSecondary: "injected error log failure"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
harness := newStateTestHarness()
|
||||
harness.extractErr = errors.New("primary pipeline failure")
|
||||
opts := harness.options()
|
||||
var terminal *recordingTerminalWriter
|
||||
opts.DebugTerminalFactory = func(delegate *debugbundle.SummaryWriter) DebugTerminalWriter {
|
||||
terminal = &recordingTerminalWriter{delegate: delegate, reportErr: tc.reportErr, errorLogErr: tc.errorLogErr}
|
||||
return terminal
|
||||
}
|
||||
|
||||
result := runStateTest(t, roots, opts, true, false, "bypass")
|
||||
if result.code != 1 {
|
||||
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
|
||||
}
|
||||
if terminal == nil {
|
||||
t.Fatal("terminal writer was not constructed")
|
||||
}
|
||||
if terminal.reportCalls != 1 || terminal.errorLogCalls != 1 {
|
||||
t.Fatalf("terminal calls = report:%d error:%d", terminal.reportCalls, terminal.errorLogCalls)
|
||||
}
|
||||
primaryIndex := strings.Index(result.stderr, "primary pipeline failure")
|
||||
secondaryIndex := strings.Index(result.stderr, tc.wantSecondary)
|
||||
debugIndex := strings.Index(result.stderr, "debug=")
|
||||
if primaryIndex < 0 || secondaryIndex <= primaryIndex || debugIndex <= secondaryIndex {
|
||||
t.Fatalf("stderr order = %q", result.stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReportFailureOnSuccessIsTerminalizedWithoutRetry(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
opts := newStateTestHarness().options()
|
||||
var terminal *recordingTerminalWriter
|
||||
opts.DebugTerminalFactory = func(delegate *debugbundle.SummaryWriter) DebugTerminalWriter {
|
||||
terminal = &recordingTerminalWriter{delegate: delegate, reportErr: errors.New("injected success report failure")}
|
||||
return terminal
|
||||
}
|
||||
|
||||
result := runStateTest(t, roots, opts, true, false, "bypass")
|
||||
if result.code != 1 || !strings.Contains(result.stderr, "write debug run report") || !strings.Contains(result.stderr, "injected success report failure") {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
|
||||
}
|
||||
if terminal == nil {
|
||||
t.Fatal("terminal writer was not constructed")
|
||||
}
|
||||
if terminal.reportCalls != 1 || terminal.errorLogCalls != 1 {
|
||||
t.Fatalf("terminal calls = report:%d error:%d", terminal.reportCalls, terminal.errorLogCalls)
|
||||
}
|
||||
if result.stdout != "" {
|
||||
t.Fatalf("stdout=%q, want no success message", result.stdout)
|
||||
}
|
||||
bundlePath := onlyChildDir(t, roots.debug)
|
||||
errorLog, err := os.ReadFile(filepath.Join(bundlePath, "summary", "error.log"))
|
||||
if err != nil || !strings.Contains(string(errorLog), "injected success report failure") {
|
||||
t.Fatalf("error log = %q, %v", errorLog, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithoutDebugDoesNotUseTerminalSummaryWriter(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
harness := newStateTestHarness()
|
||||
harness.extractErr = errors.New("non-debug pipeline failure")
|
||||
opts := harness.options()
|
||||
factoryCalls := 0
|
||||
opts.DebugTerminalFactory = func(delegate *debugbundle.SummaryWriter) DebugTerminalWriter {
|
||||
factoryCalls++
|
||||
return delegate
|
||||
}
|
||||
|
||||
result := runStateTest(t, roots, opts, false, false, "bypass")
|
||||
if result.code != 1 || !strings.Contains(result.stderr, "non-debug pipeline failure") {
|
||||
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
|
||||
}
|
||||
if factoryCalls != 0 {
|
||||
t.Fatalf("terminal summary factory calls = %d, want 0", factoryCalls)
|
||||
}
|
||||
assertAbsent(t, roots.debug)
|
||||
}
|
||||
|
||||
func TestRunRefusesExistingOutputDirectoryWithoutChangingIt(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
const runID = "run-1000000000-22222222222222222222222222222222"
|
||||
runPath := filepath.Join(roots.output, runID)
|
||||
if err := os.MkdirAll(filepath.Join(runPath, "nested"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(runPath, "sentinel"), []byte("existing output"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(runPath, "nested", "data"), []byte("preserve me"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := readTree(t, runPath)
|
||||
opts := newStateTestHarness().options()
|
||||
opts.RunIDGenerator = func(time.Time) (string, error) { return runID, nil }
|
||||
|
||||
result := runStateTest(t, roots, opts, true, false, "bypass")
|
||||
if result.code != 1 || !strings.Contains(result.stderr, "output run directory") || !strings.Contains(result.stderr, "already exists") {
|
||||
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
|
||||
}
|
||||
if after := readTree(t, runPath); !sameFiles(after, before) {
|
||||
t.Fatalf("existing output changed: before=%v after=%v", before, after)
|
||||
}
|
||||
bundlePath := filepath.Join(roots.debug, runID)
|
||||
report := readStateTestRunReport(t, bundlePath)
|
||||
if report.Succeeded || report.RunID != runID || report.OutputPath != runPath || report.DebugPath != bundlePath || report.OutputCount != 1 || report.ValidationStatus != "approved" {
|
||||
t.Fatalf("output collision report = %#v", report)
|
||||
}
|
||||
errorLog, err := os.ReadFile(filepath.Join(bundlePath, "summary", "error.log"))
|
||||
if err != nil || !strings.Contains(string(errorLog), "already exists") {
|
||||
t.Fatalf("output collision error log = %q, %v", errorLog, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatedRunIdentityCannotOverwriteFirstOutput(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
const runID = "run-1000000000-33333333333333333333333333333333"
|
||||
harness := newStateTestHarness()
|
||||
opts := harness.options()
|
||||
opts.RunIDGenerator = func(time.Time) (string, error) { return runID, nil }
|
||||
|
||||
first := runStateTest(t, roots, opts, false, false, "bypass")
|
||||
if first.code != 0 {
|
||||
t.Fatalf("first code=%d stderr=%q", first.code, first.stderr)
|
||||
}
|
||||
runPath := filepath.Join(roots.output, runID)
|
||||
before := readTree(t, runPath)
|
||||
second := runStateTest(t, roots, opts, false, false, "bypass")
|
||||
if second.code != 1 || !strings.Contains(second.stderr, "already exists") {
|
||||
t.Fatalf("second code=%d stderr=%q", second.code, second.stderr)
|
||||
}
|
||||
if after := readTree(t, runPath); !sameFiles(after, before) {
|
||||
t.Fatalf("first output changed: before=%v after=%v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRefusesExistingDebugBundleWithoutChangingIt(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
const runID = "run-1000000000-44444444444444444444444444444444"
|
||||
bundlePath := filepath.Join(roots.debug, runID)
|
||||
if err := os.MkdirAll(bundlePath, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sentinelPath := filepath.Join(bundlePath, "sentinel")
|
||||
if err := os.WriteFile(sentinelPath, []byte("existing debug"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts := newStateTestHarness().options()
|
||||
opts.RunIDGenerator = func(time.Time) (string, error) { return runID, nil }
|
||||
|
||||
result := runStateTest(t, roots, opts, true, false, "bypass")
|
||||
if result.code != 1 || !strings.Contains(result.stderr, "debug bundle") || !strings.Contains(result.stderr, "already exists") {
|
||||
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
|
||||
}
|
||||
if got, err := os.ReadFile(sentinelPath); err != nil || string(got) != "existing debug" {
|
||||
t.Fatalf("sentinel = %q, %v", got, err)
|
||||
}
|
||||
assertAbsent(t, roots.output)
|
||||
}
|
||||
|
||||
func TestRunIDGenerationFailurePrecedesDebugAllocation(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
opts := newStateTestHarness().options()
|
||||
opts.RunIDGenerator = func(time.Time) (string, error) { return "", errors.New("random source unavailable") }
|
||||
|
||||
result := runStateTest(t, roots, opts, true, false, "bypass")
|
||||
if result.code != 1 || !strings.Contains(result.stderr, "generate run ID: random source unavailable") {
|
||||
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
|
||||
}
|
||||
assertAbsent(t, roots.debug)
|
||||
assertAbsent(t, roots.output)
|
||||
}
|
||||
|
||||
func TestRunRejectsUnsafeGeneratedIdentityBeforePathUse(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
opts := newStateTestHarness().options()
|
||||
opts.RunIDGenerator = func(time.Time) (string, error) { return "../outside", nil }
|
||||
|
||||
result := runStateTest(t, roots, opts, true, false, "bypass")
|
||||
if result.code != 1 || !strings.Contains(result.stderr, "invalid generated run ID") || !strings.Contains(result.stderr, "one safe path component") {
|
||||
t.Fatalf("code=%d stderr=%q", result.code, result.stderr)
|
||||
}
|
||||
assertAbsent(t, roots.debug)
|
||||
assertAbsent(t, roots.output)
|
||||
}
|
||||
|
||||
type stateTestRoots struct{ config, input, output, plans, checkpoints, debug string }
|
||||
|
||||
func newStateTestRoots(t *testing.T) stateTestRoots {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
roots := stateTestRoots{input: filepath.Join(base, "input.txt"), output: filepath.Join(base, "output"), plans: filepath.Join(base, "plans"), checkpoints: filepath.Join(base, "checkpoints"), debug: filepath.Join(base, "debug")}
|
||||
if err := os.WriteFile(roots.input, []byte("application content Bearer secretvalue sk-secretvalue"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roots.config = filepath.Join(base, "config.yml")
|
||||
config := fmt.Sprintf("version: 3\noutput:\n directory: %q\ncache:\n chunk_plans:\n directory: %q\n mode: auto\n checkpoints:\n enabled: true\n directory: %q\ndebug:\n directory: %q\npipelines:\n sample:\n input: test/input\n chunk: test/chunk\n artifacts:\n items:\n extract: test/extract\n merge: test/merge\n normalize: test/normalize\n output: test/output\n", roots.output, roots.plans, roots.checkpoints, roots.debug)
|
||||
if err := os.WriteFile(roots.config, []byte(config), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
type stateTestResult struct {
|
||||
code int
|
||||
stdout, stderr string
|
||||
}
|
||||
|
||||
func runStateTest(t *testing.T, roots stateTestRoots, opts Options, debug, resume bool, mode string) stateTestResult {
|
||||
t.Helper()
|
||||
args := []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", mode}
|
||||
if debug {
|
||||
args = append(args, "--debug")
|
||||
}
|
||||
if resume {
|
||||
args = append(args, "--resume")
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
return stateTestResult{RunWithOptions(args, &stdout, &stderr, opts), stdout.String(), stderr.String()}
|
||||
}
|
||||
|
||||
func assertStateTestOutput(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
output := onlyChildDir(t, root)
|
||||
data, err := os.ReadFile(filepath.Join(output, "result.json"))
|
||||
if err != nil || string(data) != "{\"ok\":true}\n" {
|
||||
t.Fatalf("output = %q, %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func onlyChildDir(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var dirs []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
dirs = append(dirs, filepath.Join(root, entry.Name()))
|
||||
}
|
||||
}
|
||||
if len(dirs) != 1 {
|
||||
t.Fatalf("directories in %q = %v, want one", root, dirs)
|
||||
}
|
||||
return dirs[0]
|
||||
}
|
||||
|
||||
func assertFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if info, err := os.Stat(path); err != nil || info.IsDir() {
|
||||
t.Fatalf("file %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
func assertAbsent(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("%q exists or stat failed: %v", path, err)
|
||||
}
|
||||
}
|
||||
func assertAnyFile(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
if text := readAllFiles(t, root); text == "" {
|
||||
t.Fatalf("no files under %q", root)
|
||||
}
|
||||
}
|
||||
|
||||
func readAllFiles(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
var content strings.Builder
|
||||
if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content.Write(data)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return content.String()
|
||||
}
|
||||
|
||||
func readStateTestRunReport(t *testing.T, bundlePath string) debugbundle.RunReport {
|
||||
t.Helper()
|
||||
var report debugbundle.RunReport
|
||||
readStateTestSummaryJSON(t, bundlePath, "run-report.json", &report)
|
||||
return report
|
||||
}
|
||||
|
||||
func readStateTestSummaryJSON(t *testing.T, bundlePath, name string, target any) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(bundlePath, "summary", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRestrictedTree(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
return
|
||||
}
|
||||
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
want := os.FileMode(0o600)
|
||||
if info.IsDir() {
|
||||
want = 0o700
|
||||
}
|
||||
if info.Mode().Perm() != want {
|
||||
return fmt.Errorf("%s has mode %o, want %o", path, info.Mode().Perm(), want)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readTree(t *testing.T, root string) map[string][]byte {
|
||||
t.Helper()
|
||||
files := map[string][]byte{}
|
||||
if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relative, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files[relative] = data
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return files
|
||||
}
|
||||
func sameFiles(left, right map[string][]byte) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for path, data := range left {
|
||||
if !bytes.Equal(data, right[path]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type stateTestHarness struct {
|
||||
mu sync.Mutex
|
||||
chunkCalls, extractCalls int
|
||||
runIDCalls uint64
|
||||
extractErr error
|
||||
chunkWarnings []contracts.Warning
|
||||
moduleProfiles []string
|
||||
sessionIDs []string
|
||||
outputWarnings []contracts.Warning
|
||||
includeWarnings bool
|
||||
}
|
||||
|
||||
func newStateTestHarness() *stateTestHarness { return &stateTestHarness{} }
|
||||
func (h *stateTestHarness) options() Options {
|
||||
registries := pipeline.Registries{Inputs: pipeline.NewInputAdapterRegistry(), Chunkers: pipeline.NewChunkerRegistry(), ArtifactCodecs: pipeline.NewArtifactCodecRegistry(), Extractors: pipeline.NewExtractorRegistry(), Mergers: pipeline.NewMergerRegistry(), Normalizers: pipeline.NewNormalizerRegistry(), Validators: pipeline.NewValidatorRegistry(), ValidatorChains: pipeline.NewValidatorChainRegistry(), Outputs: pipeline.NewOutputEncoderRegistry()}
|
||||
if err := pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, stateTestCodec{}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := registries.Inputs.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "test/input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.InputAdapter, error) { return stateTestInput{}, nil }); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := registries.Chunkers.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "test/chunk", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "cache-reference"}}}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.Chunker, error) { return stateTestChunker{h}, nil }); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := pipeline.RegisterExtractor(registries.Extractors, pipeline.ModuleSpec{Key: "test/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Extractor[stateTestArtifact], error) { return stateTestExtractor{h}, nil }); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "test/merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{harness: h}, nil }); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "test/normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{harness: h}, nil }); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := registries.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "test/output", Stage: pipeline.StageOutput, Requires: []string{"normalized"}, Provides: []string{"output"}}, func() (contracts.OutputEncoder, error) {
|
||||
return stateTestOutput{harness: h, includeWarnings: h.includeWarnings}, nil
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return Options{Catalog: catalogFromRegistries(registries), Registries: registries, LookupEnv: emptyLookup, Now: func() time.Time { return time.Unix(1, 0) }, RunIDGenerator: func(startedAt time.Time) (string, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.runIDCalls++
|
||||
return fmt.Sprintf("run-%d-%032x", startedAt.UnixNano(), h.runIDCalls), nil
|
||||
}, UserCacheDir: func() (string, error) { return "", errors.New("unexpected user cache lookup") }, LLMClientFactory: func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
return nil, nil, nil
|
||||
}}
|
||||
}
|
||||
|
||||
type stateTestInput struct{}
|
||||
|
||||
func (stateTestInput) Key() string { return "test/input" }
|
||||
func (stateTestInput) Parse(_ context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) {
|
||||
doc := &source.SourceDocument{ID: "source", Kind: "text", Format: "text/plain", Units: []source.SourceUnit{{ID: 1, Kind: "text", Text: string(req.Raw), Ref: source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}}}}
|
||||
digest, err := source.DigestDocument(doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc.Digest = digest
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
type stateTestChunker struct{ harness *stateTestHarness }
|
||||
|
||||
func (stateTestChunker) Key() string { return "test/chunk" }
|
||||
func (stateTestChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
func (c stateTestChunker) Plan(_ context.Context, req contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
|
||||
c.harness.mu.Lock()
|
||||
c.harness.moduleProfiles = append(c.harness.moduleProfiles, req.LLMProfile)
|
||||
c.harness.sessionIDs = append(c.harness.sessionIDs, req.SessionID)
|
||||
c.harness.mu.Unlock()
|
||||
c.harness.mu.Lock()
|
||||
c.harness.chunkCalls++
|
||||
c.harness.mu.Unlock()
|
||||
return contracts.ChunkPlanResult{Plan: source.ChunkPlan{SourceDigest: req.Source.Digest, Ranges: []source.ChunkRange{{StartUnitID: 1, EndUnitID: 1}}}, Warnings: append([]contracts.Warning(nil), c.harness.chunkWarnings...)}, nil
|
||||
}
|
||||
|
||||
const stateTestArtifactKind contracts.ArtifactKind = "test/artifact"
|
||||
|
||||
type stateTestArtifact struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
type stateTestCodec struct{}
|
||||
|
||||
func (stateTestCodec) Kind() contracts.ArtifactKind { return stateTestArtifactKind }
|
||||
func (stateTestCodec) Schema() contracts.ArtifactSchema {
|
||||
return contracts.ArtifactSchema{ID: "test.artifact", Name: "test_artifact", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)}
|
||||
}
|
||||
func (stateTestCodec) MediaType() string { return "application/json" }
|
||||
func (stateTestCodec) EncodeCandidate(v stateTestArtifact) ([]byte, error) {
|
||||
return []byte(`{"value":"ok"}`), nil
|
||||
}
|
||||
func (stateTestCodec) Encode(v stateTestArtifact) ([]byte, error) {
|
||||
return []byte(`{"value":"ok"}`), nil
|
||||
}
|
||||
func (stateTestCodec) Decode([]byte) (stateTestArtifact, error) {
|
||||
return stateTestArtifact{Value: "ok"}, nil
|
||||
}
|
||||
|
||||
type stateTestExtractor struct{ harness *stateTestHarness }
|
||||
|
||||
func (stateTestExtractor) Key() string { return "test/extract" }
|
||||
func (stateTestExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
func (e stateTestExtractor) Extract(_ context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[stateTestArtifact], error) {
|
||||
e.harness.mu.Lock()
|
||||
defer e.harness.mu.Unlock()
|
||||
e.harness.extractCalls++
|
||||
e.harness.moduleProfiles = append(e.harness.moduleProfiles, req.LLMProfile)
|
||||
e.harness.sessionIDs = append(e.harness.sessionIDs, req.SessionID)
|
||||
if e.harness.extractErr != nil {
|
||||
return contracts.TypedExtractionResult[stateTestArtifact]{}, e.harness.extractErr
|
||||
}
|
||||
return contracts.TypedExtractionResult[stateTestArtifact]{Value: stateTestArtifact{Value: "ok"}}, nil
|
||||
}
|
||||
|
||||
type stateTestMerger struct{ harness *stateTestHarness }
|
||||
|
||||
func (stateTestMerger) Key() string { return "test/merge" }
|
||||
func (m stateTestMerger) Merge(_ context.Context, req contracts.TypedMergeRequest[stateTestArtifact]) (contracts.TypedMergeResult[stateTestArtifact], error) {
|
||||
m.harness.mu.Lock()
|
||||
m.harness.moduleProfiles = append(m.harness.moduleProfiles, req.LLMProfile)
|
||||
m.harness.sessionIDs = append(m.harness.sessionIDs, req.SessionID)
|
||||
m.harness.mu.Unlock()
|
||||
return contracts.TypedMergeResult[stateTestArtifact]{Value: req.ExtractOutputs[0].Value}, nil
|
||||
}
|
||||
|
||||
type stateTestNormalizer struct{ harness *stateTestHarness }
|
||||
|
||||
func (stateTestNormalizer) Key() string { return "test/normalize" }
|
||||
func (stateTestNormalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
func (n stateTestNormalizer) Normalize(_ context.Context, req contracts.TypedNormalizeRequest[stateTestArtifact]) (contracts.TypedNormalizeResult[stateTestArtifact], error) {
|
||||
n.harness.mu.Lock()
|
||||
n.harness.moduleProfiles = append(n.harness.moduleProfiles, req.LLMProfile)
|
||||
n.harness.sessionIDs = append(n.harness.sessionIDs, req.SessionID)
|
||||
n.harness.mu.Unlock()
|
||||
return contracts.TypedNormalizeResult[stateTestArtifact]{Value: req.MergeOutput.Value}, nil
|
||||
}
|
||||
|
||||
type stateTestOutput struct {
|
||||
harness *stateTestHarness
|
||||
includeWarnings bool
|
||||
}
|
||||
|
||||
func (o stateTestOutput) Key() string { return "test/output" }
|
||||
func (o stateTestOutput) Encode(_ context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) {
|
||||
o.harness.mu.Lock()
|
||||
o.harness.outputWarnings = append([]contracts.Warning(nil), req.Warnings...)
|
||||
o.harness.mu.Unlock()
|
||||
data := []byte("{\"ok\":true}\n")
|
||||
if o.includeWarnings && len(req.Warnings) > 0 {
|
||||
data = []byte(fmt.Sprintf("{\"ok\":true,\"warnings\":%q}\n", req.Warnings[0].ReasonCode))
|
||||
}
|
||||
return contracts.OutputResult{Files: []contracts.OutputFile{{Name: "result.json", Bytes: data}}}, nil
|
||||
}
|
||||
|
||||
type failingDebugRecorder struct{}
|
||||
|
||||
func (failingDebugRecorder) Enabled() bool { return true }
|
||||
func (failingDebugRecorder) WriteJSON(string, any) error { return errors.New("trace unavailable") }
|
||||
func (failingDebugRecorder) WriteBytes(string, []byte) error { return errors.New("trace unavailable") }
|
||||
|
||||
type recordingTerminalWriter struct {
|
||||
delegate DebugTerminalWriter
|
||||
reportErr, errorLogErr error
|
||||
reportCalls, errorLogCalls int
|
||||
}
|
||||
|
||||
func (w *recordingTerminalWriter) WriteRunReport(report debugbundle.RunReport) error {
|
||||
w.reportCalls++
|
||||
if w.reportErr != nil {
|
||||
return w.reportErr
|
||||
}
|
||||
return w.delegate.WriteRunReport(report)
|
||||
}
|
||||
|
||||
func (w *recordingTerminalWriter) WriteError(message string) error {
|
||||
w.errorLogCalls++
|
||||
if w.errorLogErr != nil {
|
||||
return w.errorLogErr
|
||||
}
|
||||
return w.delegate.WriteError(message)
|
||||
}
|
||||
@@ -1,91 +1,149 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
type ArtifactCandidate struct {
|
||||
Index int `json:"index"`
|
||||
ExtractorKey string `json:"extractor_key"`
|
||||
ArtifactType string `json:"artifact_type"`
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type Artifact struct {
|
||||
ExtractorKey string `json:"extractor_key"`
|
||||
ArtifactType string `json:"artifact_type"`
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type RejectedArtifact struct {
|
||||
Candidate ArtifactCandidate `json:"candidate"`
|
||||
ValidatorName string `json:"validator_name"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ArtifactLaneManifest struct {
|
||||
StepID string `json:"step_id,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Extractor string `json:"extractor"`
|
||||
Merger string `json:"merger"`
|
||||
Normalizer string `json:"normalizer"`
|
||||
Validators []string `json:"validators,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type ValidatorChainManifest struct {
|
||||
Stage string `json:"stage"`
|
||||
LaneID string `json:"lane_id,omitempty"`
|
||||
ModuleKey string `json:"module_key"`
|
||||
Validators []ValidatorManifest `json:"validators"`
|
||||
}
|
||||
|
||||
type ValidatorManifest struct {
|
||||
Key string `json:"key"`
|
||||
ExecutionClass string `json:"execution_class"`
|
||||
}
|
||||
|
||||
type LLMProfileManifest struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
type ReferenceProvenance struct {
|
||||
Stage string `json:"stage,omitempty"`
|
||||
StepID string `json:"step_id,omitempty"`
|
||||
LaneID string `json:"lane_id,omitempty"`
|
||||
SlotName string `json:"slot_name"`
|
||||
OriginType string `json:"origin_type"`
|
||||
OriginURI string `json:"origin_uri,omitempty"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
BindingSource string `json:"binding_source,omitempty"`
|
||||
ArtifactKind string `json:"artifact_kind,omitempty"`
|
||||
SchemaID string `json:"schema_id,omitempty"`
|
||||
SchemaName string `json:"schema_name,omitempty"`
|
||||
SchemaVersion string `json:"schema_version,omitempty"`
|
||||
SchemaDigest string `json:"schema_digest,omitempty"`
|
||||
ProducerPipeline string `json:"producer_pipeline_id,omitempty"`
|
||||
ProducerStep string `json:"producer_step_id,omitempty"`
|
||||
ProducerLane string `json:"producer_lane_id,omitempty"`
|
||||
ProducerModule string `json:"producer_module_key,omitempty"`
|
||||
}
|
||||
|
||||
type OutputSchemaProvenance struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
type NormalizedOutputManifest struct {
|
||||
StepID string `json:"step_id,omitempty"`
|
||||
LaneID string `json:"lane_id"`
|
||||
ModuleKey string `json:"module_key,omitempty"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
Schema OutputSchemaProvenance `json:"schema,omitempty"`
|
||||
}
|
||||
|
||||
type RejectedOutputManifest struct {
|
||||
Stage string `json:"stage"`
|
||||
StepID string `json:"step_id,omitempty"`
|
||||
LaneID string `json:"lane_id,omitempty"`
|
||||
ModuleKey string `json:"module_key,omitempty"`
|
||||
ChunkID string `json:"chunk_id,omitempty"`
|
||||
ChunkIndex int `json:"chunk_index,omitempty"`
|
||||
ValidatorName string `json:"validator_name,omitempty"`
|
||||
ReasonCode string `json:"reason_code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
AttemptCount int `json:"attempt_count,omitempty"`
|
||||
DiagnosticArtifactPath string `json:"diagnostic_artifact_path,omitempty"`
|
||||
}
|
||||
|
||||
type CheckpointDecisionManifest struct {
|
||||
Stage string `json:"stage"`
|
||||
StepID string `json:"step_id,omitempty"`
|
||||
LaneID string `json:"lane_id,omitempty"`
|
||||
ModuleKey string `json:"module_key,omitempty"`
|
||||
Category string `json:"category"`
|
||||
ReasonCode string `json:"reason_code,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type ChunkPlanManifest struct {
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action,omitempty"`
|
||||
SourceDigest string `json:"source_digest,omitempty"`
|
||||
PlanDigest string `json:"plan_digest,omitempty"`
|
||||
PlanSchemaVersion string `json:"plan_schema_version,omitempty"`
|
||||
RequestedModule string `json:"requested_module"`
|
||||
ProducerInputModule string `json:"producer_input_module,omitempty"`
|
||||
ProducerModule string `json:"producer_module,omitempty"`
|
||||
ProducerLLMProfile string `json:"producer_llm_profile,omitempty"`
|
||||
ProducerReferences []ReferenceProvenance `json:"producer_references,omitempty"`
|
||||
ProducerMetadata map[string]any `json:"producer_metadata,omitempty"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// ChunkPlanSummary is deliberately limited to cache and validation decisions.
|
||||
// It must never contain plan units, source content, annotations, or model I/O.
|
||||
type ChunkPlanSummary struct {
|
||||
Mode string `json:"mode"`
|
||||
SourceDigest string `json:"source_digest,omitempty"`
|
||||
CandidateDigest string `json:"candidate_digest,omitempty"`
|
||||
RequestedModule string `json:"requested_module"`
|
||||
LookupStatus string `json:"lookup_status"`
|
||||
LookupReason string `json:"lookup_reason,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
ValidationStatus string `json:"validation_status"`
|
||||
PublicationStatus string `json:"publication_status"`
|
||||
}
|
||||
|
||||
type RunManifest struct {
|
||||
RunID string `json:"run_id,omitempty"`
|
||||
PipelineID string `json:"pipeline_id,omitempty"`
|
||||
PipelineDigest string `json:"pipeline_digest,omitempty"`
|
||||
InputModule string `json:"input_module,omitempty"`
|
||||
Chunker string `json:"chunker,omitempty"`
|
||||
ChunkPlan *ChunkPlanManifest `json:"chunk_plan,omitempty"`
|
||||
SourceDigests []string `json:"source_digests,omitempty"`
|
||||
Extractors []string `json:"extractors,omitempty"`
|
||||
Merger string `json:"merger,omitempty"`
|
||||
Normalizer string `json:"normalizer,omitempty"`
|
||||
OutputEncoder string `json:"output_encoder,omitempty"`
|
||||
ModuleMetadata map[string]map[string]any `json:"module_metadata,omitempty"`
|
||||
ArtifactLanes []ArtifactLaneManifest `json:"artifact_lanes,omitempty"`
|
||||
ValidatorChains []ValidatorChainManifest `json:"validator_chains,omitempty"`
|
||||
References []ReferenceProvenance `json:"references,omitempty"`
|
||||
NormalizedOutputs []NormalizedOutputManifest `json:"normalized_outputs,omitempty"`
|
||||
RejectedOutputs []RejectedOutputManifest `json:"rejected_outputs,omitempty"`
|
||||
CheckpointDecisions []CheckpointDecisionManifest `json:"checkpoint_decisions,omitempty"`
|
||||
LLMProfiles []LLMProfileManifest `json:"llm_profiles,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
SchemaVersion string `json:"schema_version,omitempty"`
|
||||
ValidationStatus string `json:"validation_status,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
func ArtifactFromCandidate(candidate ArtifactCandidate) Artifact {
|
||||
return Artifact{
|
||||
ExtractorKey: candidate.ExtractorKey,
|
||||
ArtifactType: candidate.ArtifactType,
|
||||
SchemaVersion: candidate.SchemaVersion,
|
||||
Payload: append(json.RawMessage(nil), candidate.Payload...),
|
||||
SourceRefs: append([]source.SourceRef(nil), candidate.SourceRefs...),
|
||||
Metadata: copyMetadata(candidate.Metadata),
|
||||
}
|
||||
}
|
||||
|
||||
func copyMetadata(metadata map[string]any) map[string]any {
|
||||
if len(metadata) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
copied := make(map[string]any, len(metadata))
|
||||
for key, value := range metadata {
|
||||
copied[key] = value
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
@@ -2,116 +2,10 @@ package artifacts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
func TestArtifactFromCandidatePreservesCandidateFields(t *testing.T) {
|
||||
candidate := ArtifactCandidate{
|
||||
Index: 7,
|
||||
ExtractorKey: "generic-extractor",
|
||||
ArtifactType: "generic-artifact",
|
||||
SchemaVersion: "v1",
|
||||
Payload: json.RawMessage(`{"name":"example"}`),
|
||||
SourceRefs: []source.SourceRef{
|
||||
{SourceID: "source-1", StartUnitID: "u1", EndUnitID: "u2"},
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"confidence": 0.75,
|
||||
},
|
||||
}
|
||||
|
||||
artifact := ArtifactFromCandidate(candidate)
|
||||
|
||||
if artifact.ExtractorKey != candidate.ExtractorKey {
|
||||
t.Fatalf("ExtractorKey = %q, want %q", artifact.ExtractorKey, candidate.ExtractorKey)
|
||||
}
|
||||
if artifact.ArtifactType != candidate.ArtifactType {
|
||||
t.Fatalf("ArtifactType = %q, want %q", artifact.ArtifactType, candidate.ArtifactType)
|
||||
}
|
||||
if artifact.SchemaVersion != candidate.SchemaVersion {
|
||||
t.Fatalf("SchemaVersion = %q, want %q", artifact.SchemaVersion, candidate.SchemaVersion)
|
||||
}
|
||||
if string(artifact.Payload) != string(candidate.Payload) {
|
||||
t.Fatalf("Payload = %s, want %s", artifact.Payload, candidate.Payload)
|
||||
}
|
||||
if !reflect.DeepEqual(artifact.SourceRefs, candidate.SourceRefs) {
|
||||
t.Fatalf("SourceRefs = %#v, want %#v", artifact.SourceRefs, candidate.SourceRefs)
|
||||
}
|
||||
if !reflect.DeepEqual(artifact.Metadata, candidate.Metadata) {
|
||||
t.Fatalf("Metadata = %#v, want %#v", artifact.Metadata, candidate.Metadata)
|
||||
}
|
||||
|
||||
candidate.Payload[0] = '['
|
||||
candidate.SourceRefs[0].StartUnitID = "changed"
|
||||
candidate.Metadata["confidence"] = 0.5
|
||||
|
||||
if string(artifact.Payload) != `{"name":"example"}` {
|
||||
t.Fatalf("Payload changed after candidate mutation: %s", artifact.Payload)
|
||||
}
|
||||
if artifact.SourceRefs[0].StartUnitID != "u1" {
|
||||
t.Fatalf("SourceRefs changed after candidate mutation: %#v", artifact.SourceRefs)
|
||||
}
|
||||
if artifact.Metadata["confidence"] != 0.75 {
|
||||
t.Fatalf("Metadata changed after candidate mutation: %#v", artifact.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONMarshalUsesExpectedFieldNames(t *testing.T) {
|
||||
candidate := ArtifactCandidate{
|
||||
Index: 1,
|
||||
ExtractorKey: "generic-extractor",
|
||||
ArtifactType: "generic-artifact",
|
||||
SchemaVersion: "v1",
|
||||
Payload: json.RawMessage(`{"value":true}`),
|
||||
SourceRefs: []source.SourceRef{
|
||||
{SourceID: "source-1", StartUnitID: "u1", EndUnitID: "u1"},
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"reviewed": true,
|
||||
},
|
||||
}
|
||||
rejected := RejectedArtifact{
|
||||
Candidate: candidate,
|
||||
ValidatorName: "generic-validator",
|
||||
ReasonCode: "invalid",
|
||||
Message: "candidate was not accepted",
|
||||
}
|
||||
|
||||
gotJSON, err := json.Marshal(rejected)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(gotJSON, &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
assertHasKeys(t, got, "candidate", "validator_name", "reason_code", "message")
|
||||
|
||||
gotCandidate, ok := got["candidate"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("candidate = %#v, want object", got["candidate"])
|
||||
}
|
||||
assertHasKeys(t, gotCandidate, "index", "extractor_key", "artifact_type", "schema_version", "payload", "source_refs", "metadata")
|
||||
|
||||
gotRefs, ok := gotCandidate["source_refs"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("source_refs = %#v, want array", gotCandidate["source_refs"])
|
||||
}
|
||||
if len(gotRefs) != 1 {
|
||||
t.Fatalf("len(source_refs) = %d, want 1", len(gotRefs))
|
||||
}
|
||||
gotRef, ok := gotRefs[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("source_refs[0] = %#v, want object", gotRefs[0])
|
||||
}
|
||||
assertHasKeys(t, gotRef, "source_id", "start_unit_id", "end_unit_id")
|
||||
}
|
||||
|
||||
func TestRunManifestOmitsEmptyOptionalFields(t *testing.T) {
|
||||
gotJSON, err := json.Marshal(RunManifest{})
|
||||
if err != nil {
|
||||
@@ -123,12 +17,43 @@ func TestRunManifestOmitsEmptyOptionalFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunManifestChunkPlanIsAdditiveAndOmitsPlanContent(t *testing.T) {
|
||||
manifest := RunManifest{ChunkPlan: &ChunkPlanManifest{
|
||||
Mode: "auto", Action: "reused", SourceDigest: "sha256:source", PlanDigest: "sha256:plan",
|
||||
PlanSchemaVersion: "notarius.chunk-plan.v2", RequestedModule: "chunk/current",
|
||||
ProducerInputModule: "input/original", ProducerModule: "chunk/original",
|
||||
}}
|
||||
encoded, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(encoded)
|
||||
for _, want := range []string{`"chunk_plan"`, `"action":"reused"`, `"requested_module":"chunk/current"`, `"producer_module":"chunk/original"`} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("manifest JSON %s does not contain %s", text, want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{`"plan"`, `"units"`, `"annotations"`} {
|
||||
if strings.Contains(text, forbidden) {
|
||||
t.Fatalf("manifest JSON contains forbidden field %s: %s", forbidden, text)
|
||||
}
|
||||
}
|
||||
|
||||
var legacy RunManifest
|
||||
if err := json.Unmarshal([]byte(`{"pipeline_id":"legacy"}`), &legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if legacy.PipelineID != "legacy" || legacy.ChunkPlan != nil {
|
||||
t.Fatalf("legacy manifest = %#v", legacy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunManifestIncludesPipelineAndArtifactLaneFields(t *testing.T) {
|
||||
manifest := RunManifest{
|
||||
PipelineID: "pipeline-1",
|
||||
PipelineDigest: "sha256:abc123",
|
||||
LLMProfiles: []LLMProfileManifest{
|
||||
{ID: "default", Provider: "openai-compatible", Model: "model-a"},
|
||||
{ID: "default", Provider: "scriptorium", Model: "model-a"},
|
||||
},
|
||||
ArtifactLanes: []ArtifactLaneManifest{
|
||||
{
|
||||
@@ -136,12 +61,21 @@ func TestRunManifestIncludesPipelineAndArtifactLaneFields(t *testing.T) {
|
||||
Extractor: "event-extractor",
|
||||
Merger: "appendorder",
|
||||
Normalizer: "noop",
|
||||
Validators: []string{"grounded"},
|
||||
Metadata: map[string]any{
|
||||
"extractor": map[string]any{"prompt_id": "test.prompt"},
|
||||
},
|
||||
},
|
||||
},
|
||||
ValidatorChains: []ValidatorChainManifest{
|
||||
{
|
||||
Stage: "extract",
|
||||
LaneID: "events",
|
||||
ModuleKey: "event-extractor",
|
||||
Validators: []ValidatorManifest{
|
||||
{Key: "grounded", ExecutionClass: "deterministic"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
gotJSON, err := json.Marshal(manifest)
|
||||
@@ -154,7 +88,7 @@ func TestRunManifestIncludesPipelineAndArtifactLaneFields(t *testing.T) {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
assertHasKeys(t, got, "pipeline_id", "pipeline_digest", "artifact_lanes", "llm_profiles")
|
||||
assertHasKeys(t, got, "pipeline_id", "pipeline_digest", "artifact_lanes", "validator_chains", "llm_profiles")
|
||||
|
||||
profiles, ok := got["llm_profiles"].([]any)
|
||||
if !ok {
|
||||
@@ -180,7 +114,94 @@ func TestRunManifestIncludesPipelineAndArtifactLaneFields(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("artifact_lanes[0] = %#v, want object", lanes[0])
|
||||
}
|
||||
assertHasKeys(t, lane, "id", "extractor", "merger", "normalizer", "validators", "metadata")
|
||||
assertHasKeys(t, lane, "id", "extractor", "merger", "normalizer", "metadata")
|
||||
|
||||
chains, ok := got["validator_chains"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("validator_chains = %#v, want array", got["validator_chains"])
|
||||
}
|
||||
if len(chains) != 1 {
|
||||
t.Fatalf("len(validator_chains) = %d, want 1", len(chains))
|
||||
}
|
||||
chain, ok := chains[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("validator_chains[0] = %#v, want object", chains[0])
|
||||
}
|
||||
assertHasKeys(t, chain, "stage", "lane_id", "module_key", "validators")
|
||||
}
|
||||
|
||||
func TestRunManifestIncludesReferenceProvenance(t *testing.T) {
|
||||
manifest := RunManifest{
|
||||
References: []ReferenceProvenance{
|
||||
{
|
||||
Stage: "extract",
|
||||
LaneID: "events",
|
||||
SlotName: "roster",
|
||||
OriginType: "file",
|
||||
OriginURI: "file:///tmp/roster.txt",
|
||||
Digest: "sha256:reference",
|
||||
MediaType: "text/plain; charset=utf-8",
|
||||
SizeBytes: 12,
|
||||
BindingSource: "config",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
gotJSON, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
var got RunManifest
|
||||
if err := json.Unmarshal(gotJSON, &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(got.References) != 1 {
|
||||
t.Fatalf("len(References) = %d, want 1", len(got.References))
|
||||
}
|
||||
reference := got.References[0]
|
||||
if reference.Stage != "extract" || reference.LaneID != "events" || reference.SlotName != "roster" || reference.OriginType != "file" || reference.OriginURI != "file:///tmp/roster.txt" {
|
||||
t.Fatalf("reference provenance = %#v, want lane-scoped origin details", reference)
|
||||
}
|
||||
if reference.Digest != "sha256:reference" || reference.MediaType != "text/plain; charset=utf-8" || reference.SizeBytes != 12 || reference.BindingSource != "config" {
|
||||
t.Fatalf("reference provenance = %#v, want digest/media/size/source details", reference)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunManifestIncludesTopLevelModuleMetadata(t *testing.T) {
|
||||
manifest := RunManifest{
|
||||
ModuleMetadata: map[string]map[string]any{
|
||||
"chunker": {
|
||||
"prompt_id": "dnd.scenes",
|
||||
"prompt_version": "v1",
|
||||
"prompt_sha256": "sha256:abc123",
|
||||
"response_schema_key": "dnd_scenes",
|
||||
"response_schema_name": "dnd_scenes",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
gotJSON, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(gotJSON, &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
moduleMetadata, ok := got["module_metadata"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("module_metadata = %#v, want object", got["module_metadata"])
|
||||
}
|
||||
assertHasKeys(t, moduleMetadata, "chunker")
|
||||
|
||||
chunkerMetadata, ok := moduleMetadata["chunker"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("module_metadata.chunker = %#v, want object", moduleMetadata["chunker"])
|
||||
}
|
||||
assertHasKeys(t, chunkerMetadata, "prompt_id", "prompt_version", "prompt_sha256", "response_schema_key", "response_schema_name")
|
||||
}
|
||||
|
||||
func assertHasKeys(t *testing.T, values map[string]any, keys ...string) {
|
||||
|
||||
31
internal/core/config/cache.go
Normal file
31
internal/core/config/cache.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultChunkPlanRoot resolves the existing per-user chunk-plan cache root.
|
||||
func DefaultChunkPlanRoot(userCacheDir func() (string, error)) (string, error) {
|
||||
return defaultCacheFamilyRoot(userCacheDir, "chunk-plans")
|
||||
}
|
||||
|
||||
func DefaultCheckpointRoot(userCacheDir func() (string, error)) (string, error) {
|
||||
return defaultCacheFamilyRoot(userCacheDir, "checkpoints")
|
||||
}
|
||||
|
||||
func defaultCacheFamilyRoot(userCacheDir func() (string, error), family string) (string, error) {
|
||||
if userCacheDir == nil {
|
||||
return "", fmt.Errorf("user cache directory resolver must not be nil")
|
||||
}
|
||||
root, err := userCacheDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve user cache directory: %w", err)
|
||||
}
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return "", fmt.Errorf("user cache directory must not be empty")
|
||||
}
|
||||
return filepath.Join(filepath.Clean(root), "notarius", family), nil
|
||||
}
|
||||
@@ -1,66 +1,72 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
const SupportedFileConfigVersion = 1
|
||||
const SupportedFileConfigVersion = 3
|
||||
|
||||
type Config struct {
|
||||
LLMProfiles map[string]LLMProfile `json:"llm_profiles"`
|
||||
Scriptorium ScriptoriumConfig `json:"scriptorium,omitempty"`
|
||||
Pipelines map[string]pipeline.PipelineProfile `json:"pipelines"`
|
||||
Concurrency ConcurrencyConfig `json:"concurrency"`
|
||||
Diagnostics DiagnosticsConfig `json:"diagnostics"`
|
||||
Output OutputConfig `json:"output"`
|
||||
Cache CacheConfig `json:"cache"`
|
||||
Debug DebugConfig `json:"debug"`
|
||||
}
|
||||
|
||||
type LLMProfile struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
APIKeyEnv string `json:"api_key_env,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
MaxRetries int `json:"max_retries,omitempty"`
|
||||
MaxConcurrency int `json:"max_concurrency,omitempty"`
|
||||
type ScriptoriumConfig struct {
|
||||
ProfileDir string `json:"profile_dir,omitempty"`
|
||||
ProfileFile string `json:"profile_file,omitempty"`
|
||||
}
|
||||
|
||||
type ConcurrencyConfig struct {
|
||||
TotalLLM int `json:"total_llm"`
|
||||
StageWorkers map[string]int `json:"stage_workers"`
|
||||
|
||||
extractWorkersConfigured bool
|
||||
defaultedExtractWorkers int
|
||||
}
|
||||
|
||||
type DiagnosticsConfig struct {
|
||||
WorkDir string `json:"work_dir"`
|
||||
Retention diagnostics.RetentionMode `json:"retention"`
|
||||
type OutputConfig struct {
|
||||
Directory string `json:"directory"`
|
||||
}
|
||||
|
||||
type CacheConfig struct {
|
||||
ChunkPlans ChunkPlanCacheConfig `json:"chunk_plans"`
|
||||
Checkpoints CheckpointCacheConfig `json:"checkpoints"`
|
||||
}
|
||||
|
||||
type ChunkPlanCacheConfig struct {
|
||||
Directory string `json:"directory,omitempty"`
|
||||
Mode pipeline.ChunkCacheMode `json:"mode"`
|
||||
}
|
||||
|
||||
type CheckpointCacheConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Directory string `json:"directory,omitempty"`
|
||||
}
|
||||
type DebugConfig struct {
|
||||
Directory string `json:"directory"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
return Config{
|
||||
LLMProfiles: map[string]LLMProfile{
|
||||
pipeline.DefaultLLMProfile: {
|
||||
Provider: "openai-compatible",
|
||||
TimeoutSeconds: 600,
|
||||
MaxRetries: 3,
|
||||
MaxConcurrency: 1,
|
||||
},
|
||||
},
|
||||
Pipelines: map[string]pipeline.PipelineProfile{},
|
||||
Concurrency: ConcurrencyConfig{
|
||||
TotalLLM: 1,
|
||||
StageWorkers: map[string]int{"extract": 1},
|
||||
defaultedExtractWorkers: 1,
|
||||
},
|
||||
Diagnostics: DiagnosticsConfig{
|
||||
WorkDir: "/tmp/notarius",
|
||||
Retention: diagnostics.RetentionAuto,
|
||||
},
|
||||
Output: OutputConfig{Directory: "./notarius-output"},
|
||||
Cache: CacheConfig{ChunkPlans: ChunkPlanCacheConfig{Mode: pipeline.ChunkCacheAuto}},
|
||||
Debug: DebugConfig{Directory: "./notarius-debug"},
|
||||
}
|
||||
}
|
||||
|
||||
func cloneConfig(in Config) Config {
|
||||
out := in
|
||||
out.LLMProfiles = make(map[string]LLMProfile, len(in.LLMProfiles))
|
||||
for key, profile := range in.LLMProfiles {
|
||||
out.LLMProfiles[key] = profile
|
||||
}
|
||||
out.Concurrency.StageWorkers = cloneIntMap(in.Concurrency.StageWorkers)
|
||||
out.Pipelines = make(map[string]pipeline.PipelineProfile, len(in.Pipelines))
|
||||
for key, profile := range in.Pipelines {
|
||||
out.Pipelines[key] = clonePipelineProfile(profile)
|
||||
@@ -68,11 +74,59 @@ func cloneConfig(in Config) Config {
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneIntMap(in map[string]int) map[string]int {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]int, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *ConcurrencyConfig) recomputeStageWorkerDefaults() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if c.StageWorkers == nil {
|
||||
c.StageWorkers = make(map[string]int)
|
||||
}
|
||||
if !c.extractWorkersConfigured {
|
||||
if value, ok := c.StageWorkers["extract"]; ok && (c.defaultedExtractWorkers == 0 || value != c.defaultedExtractWorkers) {
|
||||
c.extractWorkersConfigured = true
|
||||
return
|
||||
}
|
||||
c.StageWorkers["extract"] = c.TotalLLM
|
||||
c.defaultedExtractWorkers = c.TotalLLM
|
||||
}
|
||||
}
|
||||
|
||||
func clonePipelineProfile(in pipeline.PipelineProfile) pipeline.PipelineProfile {
|
||||
out := in
|
||||
out.Input = cloneModuleBinding(in.Input)
|
||||
out.Chunk = cloneModuleBinding(in.Chunk)
|
||||
out.Output = cloneModuleBinding(in.Output)
|
||||
out.References = cloneReferenceSourceMap(in.References)
|
||||
if len(in.Artifacts) > 0 {
|
||||
out.Artifacts = make(map[string]pipeline.ArtifactLaneProfile, len(in.Artifacts))
|
||||
for key, lane := range in.Artifacts {
|
||||
out.Artifacts[key] = cloneArtifactLaneProfile(lane)
|
||||
}
|
||||
}
|
||||
if in.Steps != nil {
|
||||
out.Steps = make([]pipeline.PipelineStepProfile, len(in.Steps))
|
||||
for i, step := range in.Steps {
|
||||
out.Steps[i] = clonePipelineStepProfile(step)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clonePipelineStepProfile(in pipeline.PipelineStepProfile) pipeline.PipelineStepProfile {
|
||||
out := in
|
||||
out.ID = in.ID
|
||||
out.References = cloneReferenceSourceMap(in.References)
|
||||
if len(in.Artifacts) > 0 {
|
||||
out.Artifacts = make(map[string]pipeline.ArtifactLaneProfile, len(in.Artifacts))
|
||||
for key, lane := range in.Artifacts {
|
||||
@@ -87,6 +141,7 @@ func cloneArtifactLaneProfile(in pipeline.ArtifactLaneProfile) pipeline.Artifact
|
||||
out.Extract = cloneModuleBinding(in.Extract)
|
||||
out.Merge = cloneModuleBinding(in.Merge)
|
||||
out.Normalize = cloneModuleBinding(in.Normalize)
|
||||
out.References = cloneReferenceSourceMap(in.References)
|
||||
if len(in.Validators) > 0 {
|
||||
out.Validators = make([]pipeline.ModuleBinding, len(in.Validators))
|
||||
for i, binding := range in.Validators {
|
||||
@@ -96,11 +151,55 @@ func cloneArtifactLaneProfile(in pipeline.ArtifactLaneProfile) pipeline.Artifact
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneReferenceSourceMap(in map[string]pipeline.ReferenceSource) map[string]pipeline.ReferenceSource {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]pipeline.ReferenceSource, len(in))
|
||||
for key, source := range in {
|
||||
out[key] = cloneReferenceSource(source)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneReferenceSource(in pipeline.ReferenceSource) pipeline.ReferenceSource {
|
||||
out := in
|
||||
if in.Artifact != nil {
|
||||
artifact := *in.Artifact
|
||||
out.Artifact = &artifact
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneModuleBinding(in pipeline.ModuleBinding) pipeline.ModuleBinding {
|
||||
out := in
|
||||
if len(in.Options) > 0 {
|
||||
out.Options = cloneOptions(in.Options)
|
||||
}
|
||||
out.References = cloneReferenceSourceMap(in.References)
|
||||
out.Validators = cloneValidatorOverride(in.Validators)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneValidatorOverride(in pipeline.ValidatorOverride) pipeline.ValidatorOverride {
|
||||
out := pipeline.ValidatorOverride{Set: in.Set}
|
||||
if len(in.Validators) > 0 {
|
||||
out.Validators = make([]pipeline.ModuleBinding, len(in.Validators))
|
||||
for i, binding := range in.Validators {
|
||||
out.Validators[i] = cloneModuleBinding(binding)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestDefaultValues(t *testing.T) {
|
||||
cfg := Default()
|
||||
|
||||
defaultProfile, ok := cfg.LLMProfiles[pipeline.DefaultLLMProfile]
|
||||
if !ok {
|
||||
t.Fatalf("expected default LLM profile")
|
||||
}
|
||||
if defaultProfile.Provider != "openai-compatible" {
|
||||
t.Fatalf("unexpected provider: %q", defaultProfile.Provider)
|
||||
}
|
||||
if defaultProfile.BaseURL != "" || defaultProfile.Model != "" {
|
||||
t.Fatalf("default profile should not require base URL/model yet: %+v", defaultProfile)
|
||||
}
|
||||
if defaultProfile.TimeoutSeconds != 600 || defaultProfile.MaxRetries != 3 || defaultProfile.MaxConcurrency != 1 {
|
||||
t.Fatalf("unexpected default LLM operational values: %+v", defaultProfile)
|
||||
}
|
||||
if len(cfg.Pipelines) != 0 {
|
||||
t.Fatalf("expected no built-in pipeline profiles, got %v", cfg.Pipelines)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != 1 {
|
||||
t.Fatalf("unexpected total LLM concurrency: %d", cfg.Concurrency.TotalLLM)
|
||||
}
|
||||
if cfg.Diagnostics.WorkDir != "/tmp/notarius" {
|
||||
t.Fatalf("unexpected diagnostics work dir: %q", cfg.Diagnostics.WorkDir)
|
||||
}
|
||||
if cfg.Diagnostics.Retention != diagnostics.RetentionAuto {
|
||||
t.Fatalf("unexpected diagnostics retention: %q", cfg.Diagnostics.Retention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigMergesWithDefaults(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(`
|
||||
version: 1
|
||||
llm_profiles:
|
||||
default:
|
||||
model: test-model
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
artifacts:
|
||||
events:
|
||||
extract: fake/extract
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML: %v", err)
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
if err := cfg.applyFileConfigWithLookup(fileCfg, emptyLookup); err != nil {
|
||||
t.Fatalf("ApplyFileConfig: %v", err)
|
||||
}
|
||||
|
||||
profile := cfg.LLMProfiles[pipeline.DefaultLLMProfile]
|
||||
if profile.Model != "test-model" {
|
||||
t.Fatalf("expected file model, got %+v", profile)
|
||||
}
|
||||
if profile.Provider != "openai-compatible" || profile.TimeoutSeconds != 600 || profile.MaxRetries != 3 {
|
||||
t.Fatalf("expected default LLM fields to be preserved, got %+v", profile)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != 1 {
|
||||
t.Fatalf("expected default concurrency preserved, got %d", cfg.Concurrency.TotalLLM)
|
||||
}
|
||||
if cfg.Diagnostics.Retention != diagnostics.RetentionAuto {
|
||||
t.Fatalf("expected default diagnostics retention preserved, got %q", cfg.Diagnostics.Retention)
|
||||
}
|
||||
if _, ok := cfg.Pipelines["example"]; !ok {
|
||||
t.Fatalf("expected file pipeline to be applied")
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
@@ -14,16 +12,21 @@ type ResolveInput struct {
|
||||
Only []string
|
||||
Catalog pipeline.ModuleCatalog
|
||||
LLMProfileOverride string
|
||||
ReferenceOverrides []pipeline.ReferenceBinding
|
||||
ReferenceUnbinds []pipeline.ReferenceUnbind
|
||||
}
|
||||
|
||||
type EffectiveConfig struct {
|
||||
Config Config
|
||||
PipelineID string
|
||||
Only []string
|
||||
ReferenceOverrides []pipeline.ReferenceBinding
|
||||
ReferenceUnbinds []pipeline.ReferenceUnbind
|
||||
ResolvedPipeline pipeline.ResolvedPipeline
|
||||
}
|
||||
|
||||
func (c Config) Resolve(input ResolveInput) (EffectiveConfig, error) {
|
||||
c.Concurrency.recomputeStageWorkerDefaults()
|
||||
if err := c.Validate(); err != nil {
|
||||
return EffectiveConfig{}, err
|
||||
}
|
||||
@@ -40,13 +43,14 @@ func (c Config) Resolve(input ResolveInput) (EffectiveConfig, error) {
|
||||
profile = clonePipelineProfile(profile)
|
||||
profile.ID = pipelineID
|
||||
if override := strings.TrimSpace(input.LLMProfileOverride); override != "" {
|
||||
if !hasLLMProfile(c.LLMProfiles, override) {
|
||||
return EffectiveConfig{}, fmt.Errorf("LLM profile override %q is not configured", override)
|
||||
}
|
||||
applyLLMProfileOverride(&profile, override)
|
||||
}
|
||||
|
||||
resolved, err := pipeline.ResolvePipeline(profile, pipeline.ResolveOptions{Only: input.Only}, input.Catalog)
|
||||
resolved, err := pipeline.ResolvePipeline(profile, pipeline.ResolveOptions{
|
||||
Only: input.Only,
|
||||
ReferenceOverrides: append([]pipeline.ReferenceBinding(nil), input.ReferenceOverrides...),
|
||||
ReferenceUnbinds: append([]pipeline.ReferenceUnbind(nil), input.ReferenceUnbinds...),
|
||||
}, input.Catalog)
|
||||
if err != nil {
|
||||
return EffectiveConfig{}, fmt.Errorf("resolve pipeline %q: %w", pipelineID, err)
|
||||
}
|
||||
@@ -55,22 +59,25 @@ func (c Config) Resolve(input ResolveInput) (EffectiveConfig, error) {
|
||||
Config: cloneConfig(c),
|
||||
PipelineID: pipelineID,
|
||||
Only: append([]string(nil), input.Only...),
|
||||
ReferenceOverrides: append([]pipeline.ReferenceBinding(nil), input.ReferenceOverrides...),
|
||||
ReferenceUnbinds: append([]pipeline.ReferenceUnbind(nil), input.ReferenceUnbinds...),
|
||||
ResolvedPipeline: resolved,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func applyLLMProfileOverride(profile *pipeline.PipelineProfile, profileID string) {
|
||||
profile.Input.LLMProfile = profileID
|
||||
profile.Chunk.LLMProfile = profileID
|
||||
profile.Output.LLMProfile = profileID
|
||||
for laneID, lane := range profile.Artifacts {
|
||||
apply := func(artifacts map[string]pipeline.ArtifactLaneProfile) {
|
||||
for laneID, lane := range artifacts {
|
||||
lane.Extract.LLMProfile = profileID
|
||||
lane.Merge.LLMProfile = profileID
|
||||
lane.Normalize.LLMProfile = profileID
|
||||
for i := range lane.Validators {
|
||||
lane.Validators[i].LLMProfile = profileID
|
||||
artifacts[laneID] = lane
|
||||
}
|
||||
profile.Artifacts[laneID] = lane
|
||||
}
|
||||
apply(profile.Artifacts)
|
||||
for index := range profile.Steps {
|
||||
apply(profile.Steps[index].Artifacts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,36 +90,3 @@ func lookupPipelineProfile(profiles map[string]pipeline.PipelineProfile, pipelin
|
||||
}
|
||||
return pipeline.PipelineProfile{}, false
|
||||
}
|
||||
|
||||
func (c Config) OpenAICompatibleClientConfig(profileID string) (llm.OpenAICompatibleClientConfig, error) {
|
||||
trimmedID := strings.TrimSpace(profileID)
|
||||
profile, ok := c.LLMProfile(trimmedID)
|
||||
if !ok {
|
||||
return llm.OpenAICompatibleClientConfig{}, fmt.Errorf("LLM profile %q is not configured", trimmedID)
|
||||
}
|
||||
|
||||
provider := strings.TrimSpace(profile.Provider)
|
||||
if provider == "" {
|
||||
provider = providerOpenAICompatible
|
||||
}
|
||||
if provider != providerOpenAICompatible {
|
||||
return llm.OpenAICompatibleClientConfig{}, fmt.Errorf("LLM profile %q provider %q is not supported", trimmedID, provider)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSpace(profile.BaseURL)
|
||||
if baseURL == "" {
|
||||
return llm.OpenAICompatibleClientConfig{}, fmt.Errorf("LLM profile %q base URL must not be empty", trimmedID)
|
||||
}
|
||||
model := strings.TrimSpace(profile.Model)
|
||||
if model == "" {
|
||||
return llm.OpenAICompatibleClientConfig{}, fmt.Errorf("LLM profile %q model must not be empty", trimmedID)
|
||||
}
|
||||
|
||||
return llm.OpenAICompatibleClientConfig{
|
||||
BaseURL: baseURL,
|
||||
Model: model,
|
||||
APIKey: profile.APIKey,
|
||||
MaxRetries: profile.MaxRetries,
|
||||
RequestTimeout: time.Duration(profile.TimeoutSeconds) * time.Second,
|
||||
}, nil
|
||||
}
|
||||
|
||||
514
internal/core/config/effective_config_contract_test.go
Normal file
514
internal/core/config/effective_config_contract_test.go
Normal file
@@ -0,0 +1,514 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestEffectiveConfigRejectsEmptyAndUnknownPipelineIDs(t *testing.T) {
|
||||
cfg := configForEffectiveTests(t, effectiveProfile())
|
||||
for _, pipelineID := range []string{"", "missing"} {
|
||||
name := pipelineID
|
||||
if name == "" {
|
||||
name = "empty"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := cfg.Resolve(ResolveInput{PipelineID: pipelineID, Catalog: effectiveCatalog(t)})
|
||||
if err == nil || !strings.Contains(err.Error(), "pipeline") {
|
||||
t.Fatalf("Resolve(%q) error = %v, want pipeline context", pipelineID, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigResolvesTrimmedPipelineMapKeys(t *testing.T) {
|
||||
profile := effectiveProfile()
|
||||
profile.ID = " main "
|
||||
cfg := Default()
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{" main ": profile}
|
||||
effective, err := cfg.Resolve(ResolveInput{PipelineID: "main", Catalog: effectiveCatalog(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if effective.PipelineID != "main" || effective.ResolvedPipeline.ID != "main" {
|
||||
t.Fatalf("resolved IDs = %q, %q", effective.PipelineID, effective.ResolvedPipeline.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigOnlySelectsRequestedLanesWithoutMutatingSource(t *testing.T) {
|
||||
profile := effectiveProfile()
|
||||
profile.Artifacts["other"] = pipeline.ArtifactLaneProfile{Extract: pipeline.Binding("extract")}
|
||||
cfg := configForEffectiveTests(t, profile)
|
||||
effective, err := cfg.Resolve(ResolveInput{
|
||||
PipelineID: "main",
|
||||
Only: []string{"other"},
|
||||
Catalog: effectiveCatalog(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if len(effective.ResolvedPipeline.Steps[0].ArtifactLanes) != 1 || effective.ResolvedPipeline.Steps[0].ArtifactLanes[0].ID != "other" {
|
||||
t.Fatalf("resolved lanes = %#v", effective.ResolvedPipeline.Steps[0].ArtifactLanes)
|
||||
}
|
||||
if len(cfg.Pipelines["main"].Artifacts) != 2 {
|
||||
t.Fatalf("source lanes were mutated: %#v", cfg.Pipelines["main"].Artifacts)
|
||||
}
|
||||
|
||||
_, err = cfg.Resolve(ResolveInput{
|
||||
PipelineID: "main",
|
||||
Only: []string{"missing"},
|
||||
Catalog: effectiveCatalog(t),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "lane \"missing\"") {
|
||||
t.Fatalf("unknown lane error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigMaterializesDefaultBindingsThroughCatalog(t *testing.T) {
|
||||
effective, err := resolveEffectiveProfile(t, effectiveProfile(), ResolveInput{})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
resolved := effective.ResolvedPipeline
|
||||
if resolved.Chunk.Module != pipeline.DefaultChunkModule || resolved.Output.Module != pipeline.DefaultOutputModule {
|
||||
t.Fatalf("default pipeline bindings = %#v, %#v", resolved.Chunk, resolved.Output)
|
||||
}
|
||||
if len(resolved.Steps[0].ArtifactLanes) != 1 || resolved.Steps[0].ArtifactLanes[0].Merge.Module != pipeline.DefaultMergeModule || resolved.Steps[0].ArtifactLanes[0].Normalize.Module != pipeline.DefaultNormalizeModule {
|
||||
t.Fatalf("default lane bindings = %#v", resolved.Steps[0].ArtifactLanes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigResolutionFailuresRetainContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*pipeline.PipelineProfile)
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "unknown module",
|
||||
mutate: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Input.Module = "missing-input"
|
||||
},
|
||||
want: []string{"pipeline \"main\"", "input"},
|
||||
},
|
||||
{
|
||||
name: "missing capability",
|
||||
mutate: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk.Module = "needs-capability"
|
||||
},
|
||||
want: []string{"pipeline \"main\"", "chunk"},
|
||||
},
|
||||
{
|
||||
name: "missing artifact variant",
|
||||
mutate: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Artifacts["lane"] = pipeline.ArtifactLaneProfile{
|
||||
Extract: pipeline.Binding("extract"),
|
||||
Merge: pipeline.Binding("other-merge"),
|
||||
}
|
||||
},
|
||||
want: []string{"pipeline \"main\"", "lane \"lane\"", "merge"},
|
||||
},
|
||||
{
|
||||
name: "invalid module options",
|
||||
mutate: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk = pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"unknown": true}}
|
||||
},
|
||||
want: []string{"pipeline \"main\"", "chunk", "generic", "options"},
|
||||
},
|
||||
{
|
||||
name: "invalid validator options",
|
||||
mutate: func(profile *pipeline.PipelineProfile) {
|
||||
lane := profile.Artifacts["lane"]
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{
|
||||
Module: "option-validator",
|
||||
Options: map[string]any{"invalid": true},
|
||||
}},
|
||||
}
|
||||
profile.Artifacts["lane"] = lane
|
||||
},
|
||||
want: []string{"pipeline \"main\"", "lane \"lane\"", "extract", "option-validator", "options"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := effectiveProfile()
|
||||
tt.mutate(&profile)
|
||||
_, err := resolveEffectiveProfile(t, profile, ResolveInput{})
|
||||
if err == nil {
|
||||
t.Fatal("Resolve() error = nil, want failure")
|
||||
}
|
||||
for _, fragment := range tt.want {
|
||||
if !strings.Contains(err.Error(), fragment) {
|
||||
t.Fatalf("Resolve() error = %v, want context %q", err, fragment)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigLLMProfileOverrideChangesDigestWithoutOverridingValidators(t *testing.T) {
|
||||
profile := effectiveProfile()
|
||||
profile.Chunk.LLMProfile = "chunk-profile"
|
||||
lane := profile.Artifacts["lane"]
|
||||
lane.Extract.LLMProfile = "extract-profile"
|
||||
lane.Merge.LLMProfile = "merge-profile"
|
||||
lane.Normalize.LLMProfile = "normalize-profile"
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{
|
||||
Module: "llm-validator",
|
||||
LLMProfile: "validator-profile",
|
||||
}},
|
||||
}
|
||||
profile.Artifacts["lane"] = lane
|
||||
|
||||
base, err := resolveEffectiveProfile(t, profile, ResolveInput{})
|
||||
if err != nil {
|
||||
t.Fatalf("base Resolve() error = %v", err)
|
||||
}
|
||||
overridden, err := resolveEffectiveProfile(t, profile, ResolveInput{LLMProfileOverride: "override-profile"})
|
||||
if err != nil {
|
||||
t.Fatalf("overridden Resolve() error = %v", err)
|
||||
}
|
||||
if base.ResolvedPipeline.Digest == overridden.ResolvedPipeline.Digest {
|
||||
t.Fatal("LLM profile override did not change the pipeline digest")
|
||||
}
|
||||
resolved := overridden.ResolvedPipeline
|
||||
if resolved.Chunk.LLMProfile != "override-profile" || resolved.Steps[0].ArtifactLanes[0].Extract.LLMProfile != "override-profile" ||
|
||||
resolved.Steps[0].ArtifactLanes[0].Merge.LLMProfile != "override-profile" || resolved.Steps[0].ArtifactLanes[0].Normalize.LLMProfile != "override-profile" {
|
||||
t.Fatalf("pipeline profile override was not applied: %#v", resolved)
|
||||
}
|
||||
validators := findEffectiveValidatorChain(resolved, pipeline.StageExtract, "lane")
|
||||
if len(validators.Validators) != 1 || validators.Validators[0].Binding.LLMProfile != "validator-profile" {
|
||||
t.Fatalf("validator profile was overridden: %#v", validators)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigValidatorOverridesRemainDistinctAndOrdered(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value pipeline.ValidatorOverride
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "omitted uses default",
|
||||
want: []string{"default-validator"},
|
||||
},
|
||||
{
|
||||
name: "explicit empty",
|
||||
value: pipeline.ValidatorOverride{Set: true},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "configured order",
|
||||
value: pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding("configured-a"),
|
||||
pipeline.Binding("configured-b"),
|
||||
},
|
||||
},
|
||||
want: []string{"configured-a", "configured-b"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := effectiveProfile()
|
||||
lane := profile.Artifacts["lane"]
|
||||
lane.Extract.Validators = tt.value
|
||||
profile.Artifacts["lane"] = lane
|
||||
effective, err := resolveEffectiveProfile(t, profile, ResolveInput{})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
chain := findEffectiveValidatorChain(effective.ResolvedPipeline, pipeline.StageExtract, "lane")
|
||||
got := make([]string, len(chain.Validators))
|
||||
for i, validator := range chain.Validators {
|
||||
got[i] = validator.Binding.Module
|
||||
}
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("validator chain = %#v, want %v", got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("validator chain = %#v, want %v", got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigAndResolutionInputsDoNotAliasSource(t *testing.T) {
|
||||
profile := effectiveProfile()
|
||||
profile.Chunk.Options = map[string]any{"nested": map[string]any{"safe": "source"}}
|
||||
profile.Chunk.References = pipeline.ExternalReferenceMap(map[string]string{"chunk-ref": "chunk.txt"})
|
||||
lane := profile.Artifacts["lane"]
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{
|
||||
Module: "configured-a",
|
||||
Options: map[string]any{"nested": map[string]any{"safe": "validator-source"}},
|
||||
}},
|
||||
}
|
||||
profile.Artifacts["lane"] = lane
|
||||
cfg := configForEffectiveTests(t, profile)
|
||||
only := []string{"lane"}
|
||||
overrides := []pipeline.ReferenceBinding{{Stage: pipeline.StageChunk, SlotName: "chunk-ref", Source: "source.txt"}}
|
||||
effective, err := cfg.Resolve(ResolveInput{
|
||||
PipelineID: "main",
|
||||
Only: only,
|
||||
ReferenceOverrides: overrides,
|
||||
Catalog: effectiveCatalog(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
effective.Config.Pipelines["main"].Chunk.Options["nested"].(map[string]any)["safe"] = "effective-config"
|
||||
effective.ResolvedPipeline.Chunk.Options["nested"].(map[string]any)["safe"] = "resolved-pipeline"
|
||||
effective.ResolvedPipeline.ChunkReferences.Bindings[0].Source = "resolved-reference"
|
||||
effective.ResolvedPipeline.ValidatorChains[1].Validators[0].Binding.Options["nested"].(map[string]any)["safe"] = "resolved-validator"
|
||||
effective.Only[0] = "mutated-only"
|
||||
effective.ReferenceOverrides[0].Source = "mutated-override"
|
||||
|
||||
if got := cfg.Pipelines["main"].Chunk.Options["nested"].(map[string]any)["safe"]; got != "source" {
|
||||
t.Fatalf("source config option was aliased: %v", got)
|
||||
}
|
||||
if got := cfg.Pipelines["main"].Chunk.References["chunk-ref"].Path; got != "chunk.txt" {
|
||||
t.Fatalf("source config references were aliased: %v", got)
|
||||
}
|
||||
if only[0] != "lane" || overrides[0].Source != "source.txt" {
|
||||
t.Fatal("resolution inputs were aliased")
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveProfile() pipeline.PipelineProfile {
|
||||
return pipeline.PipelineProfile{
|
||||
ID: "main",
|
||||
Input: pipeline.Binding("input"),
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"lane": {Extract: pipeline.Binding("extract")},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func configForEffectiveTests(t *testing.T, profile pipeline.PipelineProfile) Config {
|
||||
t.Helper()
|
||||
cfg := Default()
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func resolveEffectiveProfile(t *testing.T, profile pipeline.PipelineProfile, input ResolveInput) (EffectiveConfig, error) {
|
||||
t.Helper()
|
||||
cfg := configForEffectiveTests(t, profile)
|
||||
if input.PipelineID == "" {
|
||||
input.PipelineID = "main"
|
||||
}
|
||||
if input.Catalog.Inputs == nil {
|
||||
input.Catalog = effectiveCatalog(t)
|
||||
}
|
||||
return cfg.Resolve(input)
|
||||
}
|
||||
|
||||
func findEffectiveValidatorChain(resolved pipeline.ResolvedPipeline, stage pipeline.ModuleStage, laneID string) pipeline.ResolvedValidatorChain {
|
||||
for _, chain := range resolved.ValidatorChains {
|
||||
if chain.Stage == stage && chain.LaneID == laneID {
|
||||
return chain
|
||||
}
|
||||
}
|
||||
return pipeline.ResolvedValidatorChain{}
|
||||
}
|
||||
|
||||
type effectiveArtifact struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
const effectiveArtifactKind contracts.ArtifactKind = "test/effective"
|
||||
|
||||
type effectiveCodec struct{}
|
||||
|
||||
func (effectiveCodec) Kind() contracts.ArtifactKind { return effectiveArtifactKind }
|
||||
func (effectiveCodec) Schema() contracts.ArtifactSchema {
|
||||
return contracts.ArtifactSchema{
|
||||
ID: "effective-schema",
|
||||
Name: "Effective artifact",
|
||||
Version: "1",
|
||||
JSONSchema: []byte(`{"type":"object"}`),
|
||||
}
|
||||
}
|
||||
func (effectiveCodec) MediaType() string { return "application/json" }
|
||||
func (effectiveCodec) EncodeCandidate(value effectiveArtifact) ([]byte, error) {
|
||||
return json.Marshal(value)
|
||||
}
|
||||
func (effectiveCodec) Encode(value effectiveArtifact) ([]byte, error) {
|
||||
return json.Marshal(value)
|
||||
}
|
||||
func (effectiveCodec) Decode(content []byte) (effectiveArtifact, error) {
|
||||
var value effectiveArtifact
|
||||
err := json.Unmarshal(content, &value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
type effectiveInput struct{ key string }
|
||||
|
||||
func (m effectiveInput) Key() string { return m.key }
|
||||
func (m effectiveInput) Parse(context.Context, contracts.ParseRequest) (*source.SourceDocument, error) {
|
||||
return &source.SourceDocument{}, nil
|
||||
}
|
||||
|
||||
type effectiveChunker struct{ key string }
|
||||
|
||||
func (m effectiveChunker) Key() string { return m.key }
|
||||
func (m effectiveChunker) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
if m.key == pipeline.DefaultChunkModule {
|
||||
return []contracts.ReferenceSlot{{Name: "chunk-ref"}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m effectiveChunker) Plan(context.Context, contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
|
||||
return contracts.ChunkPlanResult{}, nil
|
||||
}
|
||||
|
||||
type effectiveExtractor struct{ key string }
|
||||
|
||||
func (m effectiveExtractor) Key() string { return m.key }
|
||||
func (m effectiveExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
func (m effectiveExtractor) Extract(context.Context, contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[effectiveArtifact], error) {
|
||||
return contracts.TypedExtractionResult[effectiveArtifact]{}, nil
|
||||
}
|
||||
|
||||
type effectiveMerger struct{ key string }
|
||||
|
||||
func (m effectiveMerger) Key() string { return m.key }
|
||||
func (m effectiveMerger) Merge(context.Context, contracts.TypedMergeRequest[effectiveArtifact]) (contracts.TypedMergeResult[effectiveArtifact], error) {
|
||||
return contracts.TypedMergeResult[effectiveArtifact]{}, nil
|
||||
}
|
||||
|
||||
type effectiveNormalizer struct{ key string }
|
||||
|
||||
func (m effectiveNormalizer) Key() string { return m.key }
|
||||
func (m effectiveNormalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
func (m effectiveNormalizer) Normalize(context.Context, contracts.TypedNormalizeRequest[effectiveArtifact]) (contracts.TypedNormalizeResult[effectiveArtifact], error) {
|
||||
return contracts.TypedNormalizeResult[effectiveArtifact]{}, nil
|
||||
}
|
||||
|
||||
type effectiveOutput struct{ key string }
|
||||
|
||||
func (m effectiveOutput) Key() string { return m.key }
|
||||
func (m effectiveOutput) Encode(context.Context, contracts.OutputRequest) (contracts.OutputResult, error) {
|
||||
return contracts.OutputResult{}, nil
|
||||
}
|
||||
|
||||
type effectiveValidator struct {
|
||||
name string
|
||||
class contracts.ExecutionClass
|
||||
}
|
||||
|
||||
func (v effectiveValidator) Name() string { return v.name }
|
||||
func (v effectiveValidator) ExecutionClass() contracts.ExecutionClass { return v.class }
|
||||
func (v effectiveValidator) Validate(context.Context, contracts.TypedValidationRequest[effectiveArtifact]) (contracts.ValidationResult, error) {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
func effectiveCatalog(t *testing.T) pipeline.ModuleCatalog {
|
||||
t.Helper()
|
||||
catalog := pipeline.ModuleCatalog{
|
||||
Inputs: pipeline.NewInputAdapterRegistry(),
|
||||
Chunkers: pipeline.NewChunkerRegistry(),
|
||||
ArtifactCodecs: pipeline.NewArtifactCodecRegistry(),
|
||||
Extractors: pipeline.NewExtractorRegistry(),
|
||||
Mergers: pipeline.NewMergerRegistry(),
|
||||
Normalizers: pipeline.NewNormalizerRegistry(),
|
||||
Validators: pipeline.NewValidatorRegistry(),
|
||||
ValidatorChains: pipeline.NewValidatorChainRegistry(),
|
||||
Outputs: pipeline.NewOutputEncoderRegistry(),
|
||||
}
|
||||
if err := pipeline.RegisterArtifactCodec(catalog.ArtifactCodecs, effectiveCodec{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := catalog.Inputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func() (contracts.InputAdapter, error) {
|
||||
return effectiveInput{key: "input"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chunkSpec := pipeline.ModuleSpec{
|
||||
Key: pipeline.DefaultChunkModule,
|
||||
Stage: pipeline.StageChunk,
|
||||
Requires: []string{"source"},
|
||||
Provides: []string{"chunk"},
|
||||
ReferenceSlots: []contracts.ReferenceSlot{{Name: "chunk-ref"}},
|
||||
}
|
||||
chunkOptions := func(options map[string]any) error { return pipeline.RejectUnknownOptions(options, "size", "nested") }
|
||||
if err := catalog.Chunkers.RegisterBuilderWithSpec(chunkSpec, chunkOptions, func(pipeline.BuildRequest) (contracts.Chunker, error) {
|
||||
return effectiveChunker{key: pipeline.DefaultChunkModule}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := catalog.Chunkers.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "needs-capability", Stage: pipeline.StageChunk, Requires: []string{"missing"}}, chunkOptions, func(pipeline.BuildRequest) (contracts.Chunker, error) {
|
||||
return effectiveChunker{key: "needs-capability"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pipeline.RegisterExtractor(catalog.Extractors, pipeline.ModuleSpec{Key: "extract", Stage: pipeline.StageExtract, ArtifactKind: effectiveArtifactKind, Requires: []string{"chunk"}, Provides: []string{"candidate"}}, func() (contracts.Extractor[effectiveArtifact], error) {
|
||||
return effectiveExtractor{key: "extract"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pipeline.RegisterMerger(catalog.Mergers, pipeline.ModuleSpec{Key: pipeline.DefaultMergeModule, Stage: pipeline.StageMerge, ArtifactKind: effectiveArtifactKind, Requires: []string{"candidate"}, Provides: []string{"merged"}}, func() (contracts.Merger[effectiveArtifact], error) {
|
||||
return effectiveMerger{key: pipeline.DefaultMergeModule}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pipeline.RegisterMerger(catalog.Mergers, pipeline.ModuleSpec{Key: "other-merge", Stage: pipeline.StageMerge, ArtifactKind: "other-kind", Requires: []string{"candidate"}, Provides: []string{"merged"}}, func() (contracts.Merger[effectiveArtifact], error) {
|
||||
return effectiveMerger{key: "other-merge"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pipeline.RegisterNormalizer(catalog.Normalizers, pipeline.ModuleSpec{Key: pipeline.DefaultNormalizeModule, Stage: pipeline.StageNormalize, ArtifactKind: effectiveArtifactKind, Requires: []string{"merged"}, Provides: []string{"normalized"}}, func() (contracts.Normalizer[effectiveArtifact], error) {
|
||||
return effectiveNormalizer{key: pipeline.DefaultNormalizeModule}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := catalog.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: pipeline.DefaultOutputModule, Stage: pipeline.StageOutput, Requires: []string{"normalized"}}, func() (contracts.OutputEncoder, error) {
|
||||
return effectiveOutput{key: pipeline.DefaultOutputModule}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, validator := range []struct {
|
||||
key string
|
||||
class contracts.ExecutionClass
|
||||
}{
|
||||
{key: "default-validator", class: contracts.ExecutionClassDeterministic},
|
||||
{key: "configured-a", class: contracts.ExecutionClassDeterministic},
|
||||
{key: "configured-b", class: contracts.ExecutionClassDeterministic},
|
||||
{key: "llm-validator", class: contracts.ExecutionClassLLMBacked},
|
||||
} {
|
||||
if err := pipeline.RegisterTypedValidatorBuilder(catalog.Validators, effectiveArtifactKind, pipeline.ValidatorSpec{Key: validator.key, ExecutionClass: validator.class}, func(options map[string]any) error {
|
||||
return pipeline.RejectUnknownOptions(options, "nested")
|
||||
}, func(pipeline.BuildRequest) (contracts.TypedValidator[effectiveArtifact], error) {
|
||||
return effectiveValidator{name: validator.key, class: validator.class}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := pipeline.RegisterTypedValidatorBuilder(catalog.Validators, effectiveArtifactKind, pipeline.ValidatorSpec{Key: "option-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, func(options map[string]any) error {
|
||||
return pipeline.RejectUnknownOptions(options, "allowed")
|
||||
}, func(pipeline.BuildRequest) (contracts.TypedValidator[effectiveArtifact], error) {
|
||||
return effectiveValidator{name: "option-validator", class: contracts.ExecutionClassDeterministic}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := catalog.ValidatorChains.Register(pipeline.ValidatorChainMapping{Stage: pipeline.StageExtract, Module: "extract", Validators: []pipeline.ModuleBinding{pipeline.Binding("default-validator")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestResolveRejectsEmptyAndUnknownPipelineID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pipelineID string
|
||||
want string
|
||||
}{
|
||||
{name: "empty", pipelineID: " ", want: "pipeline id"},
|
||||
{name: "unknown", pipelineID: "missing", want: "not configured"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := validConfig().Resolve(ResolveInput{PipelineID: tc.pipelineID, Catalog: fakeCatalog(t)})
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLaneFilteringSuccessAndFailure(t *testing.T) {
|
||||
effective, err := validConfig().Resolve(ResolveInput{
|
||||
PipelineID: " example ",
|
||||
Only: []string{" notes "},
|
||||
Catalog: fakeCatalog(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
|
||||
if effective.PipelineID != "example" {
|
||||
t.Fatalf("unexpected pipeline ID: %q", effective.PipelineID)
|
||||
}
|
||||
if len(effective.ResolvedPipeline.ArtifactLanes) != 1 || effective.ResolvedPipeline.ArtifactLanes[0].ID != "notes" {
|
||||
t.Fatalf("unexpected resolved lanes: %+v", effective.ResolvedPipeline.ArtifactLanes)
|
||||
}
|
||||
if effective.ResolvedPipeline.Digest == "" {
|
||||
t.Fatalf("expected digest")
|
||||
}
|
||||
|
||||
_, err = validConfig().Resolve(ResolveInput{
|
||||
PipelineID: "example",
|
||||
Only: []string{"missing"},
|
||||
Catalog: fakeCatalog(t),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "selected artifact lane") {
|
||||
t.Fatalf("expected invalid lane error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsesTrimmedPipelineMapKeys(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.Pipelines[" example "] = cfg.Pipelines["example"]
|
||||
delete(cfg.Pipelines, "example")
|
||||
|
||||
effective, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if effective.PipelineID != "example" {
|
||||
t.Fatalf("unexpected pipeline ID: %q", effective.PipelineID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSurfacesUnknownModuleKeyThroughCatalog(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
lane := cfg.Pipelines["example"].Artifacts["events"]
|
||||
lane.Extract = pipeline.Binding("missing/extract")
|
||||
cfg.Pipelines["example"].Artifacts["events"] = lane
|
||||
|
||||
_, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
|
||||
if err == nil || !strings.Contains(err.Error(), "missing/extract") || !strings.Contains(err.Error(), "events") {
|
||||
t.Fatalf("expected unknown module error with lane context, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSurfacesMissingCapabilityThroughCatalog(t *testing.T) {
|
||||
_, err := validConfig().Resolve(ResolveInput{
|
||||
PipelineID: "example",
|
||||
Catalog: fakeCatalog(t, pipeline.ModuleSpec{
|
||||
Key: "json",
|
||||
Stage: pipeline.StageOutput,
|
||||
Requires: []string{"missing-capability"},
|
||||
}),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "missing capability") || !strings.Contains(err.Error(), "json") {
|
||||
t.Fatalf("expected missing capability error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDigestChangesWhenEffectiveConfigChanges(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
first, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve first: %v", err)
|
||||
}
|
||||
|
||||
lane := cfg.Pipelines["example"].Artifacts["events"]
|
||||
lane.Extract.Options = map[string]any{"temperature": 0.2}
|
||||
cfg.Pipelines["example"].Artifacts["events"] = lane
|
||||
second, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve second: %v", err)
|
||||
}
|
||||
|
||||
if first.ResolvedPipeline.Digest == second.ResolvedPipeline.Digest {
|
||||
t.Fatalf("expected digest to change, got %q", first.ResolvedPipeline.Digest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLLMProfileOverrideAppliesBeforeDigest(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.LLMProfiles["runtime"] = LLMProfile{Provider: "openai-compatible"}
|
||||
|
||||
base, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve base: %v", err)
|
||||
}
|
||||
effective, err := cfg.Resolve(ResolveInput{
|
||||
PipelineID: "example",
|
||||
Catalog: fakeCatalog(t),
|
||||
LLMProfileOverride: "runtime",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve override: %v", err)
|
||||
}
|
||||
|
||||
if base.ResolvedPipeline.Digest == effective.ResolvedPipeline.Digest {
|
||||
t.Fatalf("expected digest to change after LLM profile override")
|
||||
}
|
||||
for _, binding := range resolvedBindings(effective.ResolvedPipeline) {
|
||||
if binding.LLMProfile != "runtime" {
|
||||
t.Fatalf("binding profile = %q, want runtime", binding.LLMProfile)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = cfg.Resolve(ResolveInput{
|
||||
PipelineID: "example",
|
||||
Catalog: fakeCatalog(t),
|
||||
LLMProfileOverride: "missing",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "LLM profile override") {
|
||||
t.Fatalf("expected override profile error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedBindings(resolved pipeline.ResolvedPipeline) []pipeline.ModuleBinding {
|
||||
bindings := []pipeline.ModuleBinding{resolved.Input, resolved.Chunk, resolved.Output}
|
||||
for _, lane := range resolved.ArtifactLanes {
|
||||
bindings = append(bindings, lane.Extract, lane.Merge, lane.Normalize)
|
||||
bindings = append(bindings, lane.Validators...)
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleClientConfigRejectsIncompleteDefaultProfile(t *testing.T) {
|
||||
cfg := Default()
|
||||
|
||||
_, err := cfg.OpenAICompatibleClientConfig("default")
|
||||
if err == nil || !strings.Contains(err.Error(), "base URL") {
|
||||
t.Fatalf("expected incomplete profile error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleClientConfigSuccess(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
profile := cfg.LLMProfiles["default"]
|
||||
profile.APIKey = "secret"
|
||||
profile.TimeoutSeconds = 45
|
||||
profile.MaxRetries = 4
|
||||
cfg.LLMProfiles["default"] = profile
|
||||
|
||||
llmCfg, err := cfg.OpenAICompatibleClientConfig(" default ")
|
||||
if err != nil {
|
||||
t.Fatalf("OpenAICompatibleClientConfig: %v", err)
|
||||
}
|
||||
|
||||
if llmCfg.BaseURL != "https://example.invalid/v1" || llmCfg.Model != "test-model" || llmCfg.APIKey != "secret" {
|
||||
t.Fatalf("unexpected client config strings: %+v", llmCfg)
|
||||
}
|
||||
if llmCfg.MaxRetries != 4 {
|
||||
t.Fatalf("unexpected max retries: %d", llmCfg.MaxRetries)
|
||||
}
|
||||
if llmCfg.RequestTimeout != 45*time.Second {
|
||||
t.Fatalf("unexpected timeout: %s", llmCfg.RequestTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleClientConfigRejectsUnknownAndUnsupportedProfiles(t *testing.T) {
|
||||
_, err := validConfig().OpenAICompatibleClientConfig("missing")
|
||||
if err == nil || !strings.Contains(err.Error(), "not configured") {
|
||||
t.Fatalf("expected unknown profile error, got %v", err)
|
||||
}
|
||||
|
||||
cfg := validConfig()
|
||||
profile := cfg.LLMProfiles["default"]
|
||||
profile.Provider = "unsupported"
|
||||
cfg.LLMProfiles["default"] = profile
|
||||
|
||||
_, err = cfg.OpenAICompatibleClientConfig("default")
|
||||
if err == nil || !strings.Contains(err.Error(), "provider") {
|
||||
t.Fatalf("expected unsupported provider error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
@@ -30,43 +29,6 @@ func (c *Config) applyEnvOverridesWithLookup(lookup func(string) (string, bool))
|
||||
if c == nil {
|
||||
return fmt.Errorf("config must not be nil")
|
||||
}
|
||||
if c.LLMProfiles == nil {
|
||||
c.LLMProfiles = map[string]LLMProfile{}
|
||||
}
|
||||
|
||||
defaultProfile := c.LLMProfiles[pipeline.DefaultLLMProfile]
|
||||
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_API_KEY"); ok {
|
||||
defaultProfile.APIKey = raw
|
||||
}
|
||||
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_BASE_URL"); ok {
|
||||
defaultProfile.BaseURL = strings.TrimSpace(raw)
|
||||
}
|
||||
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_MODEL"); ok {
|
||||
defaultProfile.Model = strings.TrimSpace(raw)
|
||||
}
|
||||
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_TIMEOUT_SECONDS"); ok {
|
||||
value, err := parseIntEnv("NOTARIUS_LLM_DEFAULT_TIMEOUT_SECONDS", raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defaultProfile.TimeoutSeconds = value
|
||||
}
|
||||
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_MAX_RETRIES"); ok {
|
||||
value, err := parseIntEnv("NOTARIUS_LLM_DEFAULT_MAX_RETRIES", raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defaultProfile.MaxRetries = value
|
||||
}
|
||||
if raw, ok := lookup("NOTARIUS_LLM_DEFAULT_MAX_CONCURRENCY"); ok {
|
||||
value, err := parseIntEnv("NOTARIUS_LLM_DEFAULT_MAX_CONCURRENCY", raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defaultProfile.MaxConcurrency = value
|
||||
}
|
||||
c.LLMProfiles[pipeline.DefaultLLMProfile] = defaultProfile
|
||||
|
||||
if raw, ok := lookup("NOTARIUS_TOTAL_LLM_CONCURRENCY"); ok {
|
||||
value, err := parseIntEnv("NOTARIUS_TOTAL_LLM_CONCURRENCY", raw)
|
||||
if err != nil {
|
||||
@@ -74,11 +36,60 @@ func (c *Config) applyEnvOverridesWithLookup(lookup func(string) (string, bool))
|
||||
}
|
||||
c.Concurrency.TotalLLM = value
|
||||
}
|
||||
if raw, ok := lookup("NOTARIUS_WORK_DIR"); ok {
|
||||
c.Diagnostics.WorkDir = strings.TrimSpace(raw)
|
||||
if raw, ok := lookup("NOTARIUS_STAGE_WORKERS_EXTRACT"); ok {
|
||||
value, err := parseIntEnv("NOTARIUS_STAGE_WORKERS_EXTRACT", raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Concurrency.StageWorkers == nil {
|
||||
c.Concurrency.StageWorkers = make(map[string]int)
|
||||
}
|
||||
c.Concurrency.StageWorkers["extract"] = value
|
||||
c.Concurrency.extractWorkersConfigured = true
|
||||
}
|
||||
c.Concurrency.recomputeStageWorkerDefaults()
|
||||
if raw, ok := lookup("NOTARIUS_OUTPUT_DIR"); ok {
|
||||
c.Output.Directory = strings.TrimSpace(raw)
|
||||
if c.Output.Directory == "" {
|
||||
return fmt.Errorf("NOTARIUS_OUTPUT_DIR: must not be empty")
|
||||
}
|
||||
if strings.ContainsRune(c.Output.Directory, '\x00') {
|
||||
return fmt.Errorf("NOTARIUS_OUTPUT_DIR: must not contain NUL")
|
||||
}
|
||||
}
|
||||
if raw, ok := lookup("NOTARIUS_CACHE_CHUNK_PLANS_MODE"); ok {
|
||||
mode, err := pipeline.ParseChunkCacheMode(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("NOTARIUS_CACHE_CHUNK_PLANS_MODE: %w", err)
|
||||
}
|
||||
c.Cache.ChunkPlans.Mode = mode
|
||||
}
|
||||
if raw, ok := lookup("NOTARIUS_CACHE_CHUNK_PLANS_DIR"); ok {
|
||||
c.Cache.ChunkPlans.Directory = cleanOptionalPath(raw)
|
||||
if c.Cache.ChunkPlans.Directory == "" {
|
||||
return fmt.Errorf("NOTARIUS_CACHE_CHUNK_PLANS_DIR: must not be empty")
|
||||
}
|
||||
if strings.ContainsRune(c.Cache.ChunkPlans.Directory, '\x00') {
|
||||
return fmt.Errorf("NOTARIUS_CACHE_CHUNK_PLANS_DIR: must not contain NUL")
|
||||
}
|
||||
}
|
||||
if raw, ok := lookup("NOTARIUS_CACHE_CHECKPOINTS_DIR"); ok {
|
||||
c.Cache.Checkpoints.Directory = cleanOptionalPath(raw)
|
||||
if c.Cache.Checkpoints.Directory == "" {
|
||||
return fmt.Errorf("NOTARIUS_CACHE_CHECKPOINTS_DIR: must not be empty")
|
||||
}
|
||||
if strings.ContainsRune(c.Cache.Checkpoints.Directory, '\x00') {
|
||||
return fmt.Errorf("NOTARIUS_CACHE_CHECKPOINTS_DIR: must not contain NUL")
|
||||
}
|
||||
}
|
||||
if raw, ok := lookup("NOTARIUS_DEBUG_DIR"); ok {
|
||||
c.Debug.Directory = strings.TrimSpace(raw)
|
||||
if c.Debug.Directory == "" {
|
||||
return fmt.Errorf("NOTARIUS_DEBUG_DIR: must not be empty")
|
||||
}
|
||||
if strings.ContainsRune(c.Debug.Directory, '\x00') {
|
||||
return fmt.Errorf("NOTARIUS_DEBUG_DIR: must not contain NUL")
|
||||
}
|
||||
if raw, ok := lookup("NOTARIUS_DIAGNOSTICS_RETENTION"); ok {
|
||||
c.Diagnostics.Retention = diagnostics.RetentionMode(strings.TrimSpace(raw))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
239
internal/core/config/env_contract_test.go
Normal file
239
internal/core/config/env_contract_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestPrecedenceFileValuesOverrideBuiltInDefaults(t *testing.T) {
|
||||
cfg := applyFileConfig(t, `version: 3
|
||||
concurrency:
|
||||
total_llm: 4
|
||||
stage_workers:
|
||||
extract: 2
|
||||
output:
|
||||
directory: ./file-output
|
||||
cache:
|
||||
chunk_plans:
|
||||
directory: ./file-plans
|
||||
mode: refresh
|
||||
checkpoints:
|
||||
directory: ./file-checkpoints
|
||||
debug:
|
||||
directory: ./file-debug
|
||||
`)
|
||||
if cfg.Concurrency.TotalLLM != 4 || cfg.Concurrency.StageWorkers["extract"] != 2 ||
|
||||
cfg.Output.Directory != "./file-output" || cfg.Cache.ChunkPlans.Directory != "file-plans" ||
|
||||
cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheRefresh || cfg.Cache.Checkpoints.Directory != "file-checkpoints" ||
|
||||
cfg.Debug.Directory != "./file-debug" {
|
||||
t.Fatalf("file values did not override defaults: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecedenceOperationalEnvironmentOverridesFileValues(t *testing.T) {
|
||||
cfg := applyFileConfig(t, `version: 3
|
||||
concurrency:
|
||||
total_llm: 2
|
||||
stage_workers:
|
||||
extract: 1
|
||||
output:
|
||||
directory: ./file-output
|
||||
cache:
|
||||
chunk_plans:
|
||||
directory: ./file-plans
|
||||
mode: refresh
|
||||
checkpoints:
|
||||
directory: ./file-checkpoints
|
||||
debug:
|
||||
directory: ./file-debug
|
||||
`)
|
||||
env := map[string]string{
|
||||
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "8",
|
||||
"NOTARIUS_STAGE_WORKERS_EXTRACT": "6",
|
||||
"NOTARIUS_OUTPUT_DIR": "/env/output",
|
||||
"NOTARIUS_CACHE_CHUNK_PLANS_MODE": "bypass",
|
||||
"NOTARIUS_CACHE_CHUNK_PLANS_DIR": "/env/plans",
|
||||
"NOTARIUS_CACHE_CHECKPOINTS_DIR": "/env/checkpoints",
|
||||
"NOTARIUS_DEBUG_DIR": "/env/debug",
|
||||
}
|
||||
if err := cfg.ApplyEnvOverridesWithLookup(lookupValues(env)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != 8 || cfg.Concurrency.StageWorkers["extract"] != 6 ||
|
||||
cfg.Output.Directory != "/env/output" || cfg.Cache.ChunkPlans.Directory != "/env/plans" ||
|
||||
cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheBypass || cfg.Cache.Checkpoints.Directory != "/env/checkpoints" ||
|
||||
cfg.Debug.Directory != "/env/debug" {
|
||||
t.Fatalf("environment values did not override file values: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecedenceExtractWorkersFollowEffectiveConcurrencyUnlessExplicit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
env map[string]string
|
||||
wantTotal int
|
||||
wantWorker int
|
||||
}{
|
||||
{
|
||||
name: "default follows environment total",
|
||||
file: "version: 3\n",
|
||||
env: map[string]string{"NOTARIUS_TOTAL_LLM_CONCURRENCY": "5"},
|
||||
wantTotal: 5,
|
||||
wantWorker: 5,
|
||||
},
|
||||
{
|
||||
name: "file worker is retained",
|
||||
file: "version: 3\nconcurrency:\n total_llm: 3\n stage_workers:\n extract: 2\n",
|
||||
env: map[string]string{"NOTARIUS_TOTAL_LLM_CONCURRENCY": "6"},
|
||||
wantTotal: 6,
|
||||
wantWorker: 2,
|
||||
},
|
||||
{
|
||||
name: "environment worker is retained",
|
||||
file: "version: 3\nconcurrency:\n total_llm: 2\n",
|
||||
env: map[string]string{
|
||||
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "6",
|
||||
"NOTARIUS_STAGE_WORKERS_EXTRACT": "4",
|
||||
},
|
||||
wantTotal: 6,
|
||||
wantWorker: 4,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := applyFileConfig(t, tt.file)
|
||||
if err := cfg.ApplyEnvOverridesWithLookup(lookupValues(tt.env)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != tt.wantTotal || cfg.Concurrency.StageWorkers["extract"] != tt.wantWorker {
|
||||
t.Fatalf("concurrency = %#v, want total %d and extract %d", cfg.Concurrency, tt.wantTotal, tt.wantWorker)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecedenceEmptyFileCacheDirectoriesDeferPerUserResolution(t *testing.T) {
|
||||
cfg := applyFileConfig(t, `version: 3
|
||||
cache:
|
||||
chunk_plans:
|
||||
directory: ""
|
||||
checkpoints:
|
||||
directory: ""
|
||||
`)
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("empty file cache directories should be valid: %v", err)
|
||||
}
|
||||
if cfg.Cache.ChunkPlans.Directory != "" || cfg.Cache.Checkpoints.Directory != "" {
|
||||
t.Fatalf("empty cache directories were not preserved for deferred resolution: %#v", cfg.Cache)
|
||||
}
|
||||
resolver := func() (string, error) { return "/user/cache", nil }
|
||||
chunkPlans, err := DefaultChunkPlanRoot(resolver)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkpoints, err := DefaultCheckpointRoot(resolver)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if chunkPlans != "/user/cache/notarius/chunk-plans" || checkpoints != "/user/cache/notarius/checkpoints" {
|
||||
t.Fatalf("deferred cache roots = %q, %q", chunkPlans, checkpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCacheRootsRejectInvalidUserCacheResolvers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resolver func() (string, error)
|
||||
want string
|
||||
}{
|
||||
{name: "nil resolver", want: "must not be nil"},
|
||||
{
|
||||
name: "resolver failure",
|
||||
resolver: func() (string, error) {
|
||||
return "", errors.New("cache home unavailable")
|
||||
},
|
||||
want: "resolve user cache directory",
|
||||
},
|
||||
{name: "empty directory", resolver: func() (string, error) { return " ", nil }, want: "must not be empty"},
|
||||
}
|
||||
families := []struct {
|
||||
name string
|
||||
root func(func() (string, error)) (string, error)
|
||||
}{
|
||||
{name: "chunk plans", root: DefaultChunkPlanRoot},
|
||||
{name: "checkpoints", root: DefaultCheckpointRoot},
|
||||
}
|
||||
|
||||
for _, family := range families {
|
||||
for _, tt := range tests {
|
||||
t.Run(family.name+"/"+tt.name, func(t *testing.T) {
|
||||
_, err := family.root(tt.resolver)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvEmptyDirectoryOverridesAreErrors(t *testing.T) {
|
||||
tests := []string{
|
||||
"NOTARIUS_OUTPUT_DIR",
|
||||
"NOTARIUS_CACHE_CHUNK_PLANS_DIR",
|
||||
"NOTARIUS_CACHE_CHECKPOINTS_DIR",
|
||||
"NOTARIUS_DEBUG_DIR",
|
||||
}
|
||||
for _, name := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
err := cfg.ApplyEnvOverridesWithLookup(lookupValues(map[string]string{name: " \t"}))
|
||||
if err == nil || !strings.Contains(err.Error(), name) {
|
||||
t.Fatalf("error = %v, want responsible environment variable", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvInvalidIntegersAndChunkCacheModesReportTheirNames(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "not-an-integer",
|
||||
"NOTARIUS_STAGE_WORKERS_EXTRACT": "not-an-integer",
|
||||
"NOTARIUS_CACHE_CHUNK_PLANS_MODE": "not-a-cache-mode",
|
||||
}
|
||||
for name, value := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
err := cfg.ApplyEnvOverridesWithLookup(lookupValues(map[string]string{name: value}))
|
||||
if err == nil || !strings.Contains(err.Error(), name) {
|
||||
t.Fatalf("error = %v, want responsible environment variable", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvRemovedProviderVariablesAreIgnored(t *testing.T) {
|
||||
before := Default()
|
||||
cfg := Default()
|
||||
removed := map[string]string{
|
||||
"NOTARIUS_LLM_DEFAULT_ENDPOINT": "ignored-provider-setting",
|
||||
"NOTARIUS_LLM_DEFAULT_MODEL": "ignored-provider-setting",
|
||||
}
|
||||
if err := cfg.ApplyEnvOverridesWithLookup(lookupValues(removed)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg, before) {
|
||||
t.Fatalf("removed provider variables changed configuration: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func lookupValues(values map[string]string) func(string) (string, bool) {
|
||||
return func(name string) (string, bool) {
|
||||
value, ok := values[name]
|
||||
return value, ok
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestApplyEnvOverridesOperationalAndLLMValues(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Pipelines["example"] = pipeline.PipelineProfile{ID: "example", Input: pipeline.Binding("before")}
|
||||
|
||||
err := cfg.applyEnvOverridesWithLookup(mapLookup(map[string]string{
|
||||
"NOTARIUS_LLM_DEFAULT_API_KEY": "secret",
|
||||
"NOTARIUS_LLM_DEFAULT_BASE_URL": "https://example.invalid/v1",
|
||||
"NOTARIUS_LLM_DEFAULT_MODEL": "test-model",
|
||||
"NOTARIUS_LLM_DEFAULT_TIMEOUT_SECONDS": "120",
|
||||
"NOTARIUS_LLM_DEFAULT_MAX_RETRIES": "5",
|
||||
"NOTARIUS_LLM_DEFAULT_MAX_CONCURRENCY": "2",
|
||||
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "3",
|
||||
"NOTARIUS_WORK_DIR": "/tmp/notarius-env",
|
||||
"NOTARIUS_DIAGNOSTICS_RETENTION": "never",
|
||||
"NOTARIUS_PIPELINE_INPUT": "after",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyEnvOverrides: %v", err)
|
||||
}
|
||||
|
||||
profile := cfg.LLMProfiles[pipeline.DefaultLLMProfile]
|
||||
if profile.APIKey != "secret" || profile.BaseURL != "https://example.invalid/v1" || profile.Model != "test-model" {
|
||||
t.Fatalf("unexpected LLM profile strings: %+v", profile)
|
||||
}
|
||||
if profile.TimeoutSeconds != 120 || profile.MaxRetries != 5 || profile.MaxConcurrency != 2 {
|
||||
t.Fatalf("unexpected LLM profile numeric values: %+v", profile)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != 3 {
|
||||
t.Fatalf("unexpected total concurrency: %d", cfg.Concurrency.TotalLLM)
|
||||
}
|
||||
if cfg.Diagnostics.WorkDir != "/tmp/notarius-env" || cfg.Diagnostics.Retention != diagnostics.RetentionNever {
|
||||
t.Fatalf("unexpected diagnostics config: %+v", cfg.Diagnostics)
|
||||
}
|
||||
if cfg.Pipelines["example"].Input.Module != "before" {
|
||||
t.Fatalf("environment overrides must not change pipeline wiring: %+v", cfg.Pipelines["example"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvOverridesRejectsInvalidIntegers(t *testing.T) {
|
||||
cfg := Default()
|
||||
err := cfg.applyEnvOverridesWithLookup(mapLookup(map[string]string{
|
||||
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "many",
|
||||
}))
|
||||
if err == nil || !strings.Contains(err.Error(), "NOTARIUS_TOTAL_LLM_CONCURRENCY") {
|
||||
t.Fatalf("expected named integer error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvUsesDefaultConfig(t *testing.T) {
|
||||
t.Setenv("NOTARIUS_LLM_DEFAULT_MODEL", "env-model")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromEnv: %v", err)
|
||||
}
|
||||
if cfg.LLMProfiles[pipeline.DefaultLLMProfile].Model != "env-model" {
|
||||
t.Fatalf("expected env model, got %+v", cfg.LLMProfiles[pipeline.DefaultLLMProfile])
|
||||
}
|
||||
}
|
||||
@@ -4,41 +4,104 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
type FileConfig struct {
|
||||
Version int `yaml:"version"`
|
||||
LLMProfiles map[string]FileLLMProfile `yaml:"llm_profiles,omitempty"`
|
||||
Scriptorium *FileScriptoriumConfig `yaml:"scriptorium,omitempty"`
|
||||
Pipelines map[string]FilePipelineProfile `yaml:"pipelines,omitempty"`
|
||||
Concurrency *FileConcurrencyConfig `yaml:"concurrency,omitempty"`
|
||||
Diagnostics *FileDiagnosticsConfig `yaml:"diagnostics,omitempty"`
|
||||
Output *FileOutputConfig `yaml:"output,omitempty"`
|
||||
Cache *FileCacheConfig `yaml:"cache,omitempty"`
|
||||
Debug *FileDebugConfig `yaml:"debug,omitempty"`
|
||||
}
|
||||
|
||||
type FileLLMProfile struct {
|
||||
Provider *string `yaml:"provider,omitempty"`
|
||||
BaseURL *string `yaml:"base_url,omitempty"`
|
||||
Model *string `yaml:"model,omitempty"`
|
||||
APIKeyEnv *string `yaml:"api_key_env,omitempty"`
|
||||
Timeout *fileDurationSeconds `yaml:"timeout,omitempty"`
|
||||
MaxRetries *int `yaml:"max_retries,omitempty"`
|
||||
MaxConcurrency *int `yaml:"max_concurrency,omitempty"`
|
||||
type FileScriptoriumConfig struct {
|
||||
ProfileDir *string `yaml:"profile_dir,omitempty"`
|
||||
ProfileFile *string `yaml:"profile_file,omitempty"`
|
||||
}
|
||||
|
||||
type FilePipelineProfile struct {
|
||||
Input fileModuleBinding `yaml:"input"`
|
||||
Chunk *fileModuleBinding `yaml:"chunk,omitempty"`
|
||||
Artifacts map[string]FileArtifactLaneProfile `yaml:"artifacts,omitempty"`
|
||||
Steps []FilePipelineStepProfile `yaml:"steps,omitempty"`
|
||||
Output *fileModuleBinding `yaml:"output,omitempty"`
|
||||
References map[string]fileReferenceSource `yaml:"references,omitempty"`
|
||||
artifactsSet bool `yaml:"-"`
|
||||
stepsSet bool `yaml:"-"`
|
||||
}
|
||||
|
||||
func (p *FilePipelineProfile) UnmarshalYAML(node *yaml.Node) error {
|
||||
type plainFilePipelineProfile FilePipelineProfile
|
||||
var decoded plainFilePipelineProfile
|
||||
seen, err := decodeKnownMapping(node, &decoded, map[string]struct{}{
|
||||
"input": {}, "chunk": {}, "artifacts": {}, "steps": {}, "output": {}, "references": {},
|
||||
}, "pipeline profile")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*p = FilePipelineProfile(decoded)
|
||||
_, p.artifactsSet = seen["artifacts"]
|
||||
_, p.stepsSet = seen["steps"]
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FilePipelineStepProfile) UnmarshalYAML(node *yaml.Node) error {
|
||||
type plainFilePipelineStepProfile FilePipelineStepProfile
|
||||
var decoded plainFilePipelineStepProfile
|
||||
if _, err := decodeKnownMapping(node, &decoded, map[string]struct{}{
|
||||
"id": {}, "artifacts": {}, "references": {},
|
||||
}, "pipeline step"); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = FilePipelineStepProfile(decoded)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *FileArtifactLaneProfile) UnmarshalYAML(node *yaml.Node) error {
|
||||
type plainFileArtifactLaneProfile FileArtifactLaneProfile
|
||||
var decoded plainFileArtifactLaneProfile
|
||||
if _, err := decodeKnownMapping(node, &decoded, map[string]struct{}{
|
||||
"extract": {}, "merge": {}, "normalize": {}, "validators": {}, "references": {},
|
||||
}, "artifact lane"); err != nil {
|
||||
return err
|
||||
}
|
||||
*l = FileArtifactLaneProfile(decoded)
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeKnownMapping(node *yaml.Node, target any, allowed map[string]struct{}, context string) (map[string]struct{}, error) {
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return nil, fmt.Errorf("%s must be an object", context)
|
||||
}
|
||||
if err := node.Decode(target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]struct{}, len(node.Content)/2)
|
||||
for i := 0; i < len(node.Content); i += 2 {
|
||||
key := node.Content[i].Value
|
||||
if _, exists := seen[key]; exists {
|
||||
return nil, fmt.Errorf("%s field %q is duplicated", context, key)
|
||||
}
|
||||
if _, ok := allowed[key]; !ok {
|
||||
return nil, fmt.Errorf("field %s not found in %s", key, context)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
return seen, nil
|
||||
}
|
||||
|
||||
type FilePipelineStepProfile struct {
|
||||
ID string `yaml:"id"`
|
||||
Artifacts map[string]FileArtifactLaneProfile `yaml:"artifacts"`
|
||||
References map[string]fileReferenceSource `yaml:"references,omitempty"`
|
||||
}
|
||||
|
||||
type FileArtifactLaneProfile struct {
|
||||
@@ -46,57 +109,120 @@ type FileArtifactLaneProfile struct {
|
||||
Merge *fileModuleBinding `yaml:"merge,omitempty"`
|
||||
Normalize *fileModuleBinding `yaml:"normalize,omitempty"`
|
||||
Validators []fileModuleBinding `yaml:"validators,omitempty"`
|
||||
References map[string]fileReferenceSource `yaml:"references,omitempty"`
|
||||
}
|
||||
|
||||
type FileConcurrencyConfig struct {
|
||||
TotalLLM *int `yaml:"total_llm,omitempty"`
|
||||
StageWorkers map[string]int `yaml:"stage_workers,omitempty"`
|
||||
}
|
||||
|
||||
type FileDiagnosticsConfig struct {
|
||||
WorkDir *string `yaml:"work_dir,omitempty"`
|
||||
Retention *string `yaml:"retention,omitempty"`
|
||||
type FileOutputConfig struct {
|
||||
Directory *string `yaml:"directory,omitempty"`
|
||||
}
|
||||
|
||||
type fileDurationSeconds struct {
|
||||
seconds int
|
||||
type FileCacheConfig struct {
|
||||
ChunkPlans *FileChunkPlanCacheConfig `yaml:"chunk_plans,omitempty"`
|
||||
Checkpoints *FileCheckpointCacheConfig `yaml:"checkpoints,omitempty"`
|
||||
}
|
||||
|
||||
func (d *fileDurationSeconds) UnmarshalYAML(node *yaml.Node) error {
|
||||
if node.Kind != yaml.ScalarNode {
|
||||
return fmt.Errorf("must be an integer seconds value or duration string")
|
||||
type FileChunkPlanCacheConfig struct {
|
||||
Directory *string `yaml:"directory,omitempty"`
|
||||
Mode *string `yaml:"mode,omitempty"`
|
||||
}
|
||||
if node.Tag == "!!int" {
|
||||
var seconds int
|
||||
if err := node.Decode(&seconds); err != nil {
|
||||
return fmt.Errorf("must be an integer seconds value or duration string")
|
||||
type FileCheckpointCacheConfig struct {
|
||||
Enabled *bool `yaml:"enabled,omitempty"`
|
||||
Directory *string `yaml:"directory,omitempty"`
|
||||
}
|
||||
d.seconds = seconds
|
||||
return nil
|
||||
}
|
||||
|
||||
var raw string
|
||||
if err := node.Decode(&raw); err != nil {
|
||||
return fmt.Errorf("must be an integer seconds value or duration string")
|
||||
}
|
||||
duration, err := time.ParseDuration(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration %q", raw)
|
||||
}
|
||||
if duration%time.Second != 0 {
|
||||
return fmt.Errorf("duration %q must resolve to whole seconds", raw)
|
||||
}
|
||||
d.seconds = int(duration / time.Second)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d fileDurationSeconds) Seconds() int {
|
||||
return d.seconds
|
||||
type FileDebugConfig struct {
|
||||
Directory *string `yaml:"directory,omitempty"`
|
||||
}
|
||||
|
||||
type fileModuleBinding struct {
|
||||
Module string
|
||||
LLMProfile string
|
||||
Retries int
|
||||
Options map[string]any
|
||||
References map[string]fileReferenceSource
|
||||
Validators pipeline.ValidatorOverride
|
||||
}
|
||||
|
||||
type fileReferenceSource struct {
|
||||
path string
|
||||
artifact *pipeline.ArtifactReference
|
||||
}
|
||||
|
||||
func (source *fileReferenceSource) UnmarshalYAML(node *yaml.Node) error {
|
||||
if source == nil {
|
||||
return fmt.Errorf("reference source must not be nil")
|
||||
}
|
||||
switch node.Kind {
|
||||
case yaml.ScalarNode:
|
||||
if node.Tag != "!!str" {
|
||||
return fmt.Errorf("external reference path must be a string")
|
||||
}
|
||||
path := strings.TrimSpace(node.Value)
|
||||
if path == "" {
|
||||
return fmt.Errorf("external reference path must not be empty")
|
||||
}
|
||||
source.path = path
|
||||
source.artifact = nil
|
||||
return nil
|
||||
case yaml.MappingNode:
|
||||
if len(node.Content) != 2 || node.Content[0].Value != "artifact" {
|
||||
return fmt.Errorf("reference source mapping must contain only artifact")
|
||||
}
|
||||
artifactNode := node.Content[1]
|
||||
if artifactNode.Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("artifact reference must be an object")
|
||||
}
|
||||
var step, lane string
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < len(artifactNode.Content); i += 2 {
|
||||
key := artifactNode.Content[i].Value
|
||||
value := artifactNode.Content[i+1]
|
||||
if seen[key] {
|
||||
return fmt.Errorf("artifact reference field %q is duplicated", key)
|
||||
}
|
||||
seen[key] = true
|
||||
if value.Tag != "!!str" {
|
||||
return fmt.Errorf("artifact reference field %q must be a string", key)
|
||||
}
|
||||
switch key {
|
||||
case "step":
|
||||
step = strings.TrimSpace(value.Value)
|
||||
case "lane":
|
||||
lane = strings.TrimSpace(value.Value)
|
||||
default:
|
||||
return fmt.Errorf("field %s not found in artifact reference", key)
|
||||
}
|
||||
}
|
||||
if step == "" || lane == "" {
|
||||
return fmt.Errorf("artifact reference step and lane must not be empty")
|
||||
}
|
||||
source.path = ""
|
||||
source.artifact = &pipeline.ArtifactReference{Step: step, Lane: lane}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("reference source must be a string or object")
|
||||
}
|
||||
}
|
||||
|
||||
func (source fileReferenceSource) toPipelineSource() pipeline.ReferenceSource {
|
||||
if source.artifact != nil {
|
||||
artifact := *source.artifact
|
||||
return pipeline.ReferenceSource{Artifact: &artifact}
|
||||
}
|
||||
return pipeline.ExternalReference(source.path)
|
||||
}
|
||||
|
||||
func fileReferenceSourcesToPipeline(values map[string]fileReferenceSource) map[string]pipeline.ReferenceSource {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]pipeline.ReferenceSource, len(values))
|
||||
for key, value := range values {
|
||||
out[strings.TrimSpace(key)] = value.toPipelineSource()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *fileModuleBinding) UnmarshalYAML(node *yaml.Node) error {
|
||||
@@ -125,12 +251,34 @@ func (b *fileModuleBinding) UnmarshalYAML(node *yaml.Node) error {
|
||||
return err
|
||||
}
|
||||
b.LLMProfile = strings.TrimSpace(llmProfile)
|
||||
case "retries":
|
||||
var retries int
|
||||
if err := valueNode.Decode(&retries); err != nil {
|
||||
return err
|
||||
}
|
||||
b.Retries = retries
|
||||
case "options":
|
||||
var options map[string]any
|
||||
if err := valueNode.Decode(&options); err != nil {
|
||||
return err
|
||||
}
|
||||
b.Options = normalizeOptions(options)
|
||||
case "references":
|
||||
var references map[string]fileReferenceSource
|
||||
if err := valueNode.Decode(&references); err != nil {
|
||||
return err
|
||||
}
|
||||
b.References = references
|
||||
case "validators":
|
||||
b.Validators.Set = true
|
||||
var validators []fileModuleBinding
|
||||
if err := valueNode.Decode(&validators); err != nil {
|
||||
return err
|
||||
}
|
||||
b.Validators.Validators = make([]pipeline.ModuleBinding, len(validators))
|
||||
for i, validator := range validators {
|
||||
b.Validators.Validators[i] = validator.toPipelineBinding()
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("field %s not found in module binding", keyNode.Value)
|
||||
}
|
||||
@@ -145,7 +293,10 @@ func (b fileModuleBinding) toPipelineBinding() pipeline.ModuleBinding {
|
||||
return pipeline.ModuleBinding{
|
||||
Module: strings.TrimSpace(b.Module),
|
||||
LLMProfile: strings.TrimSpace(b.LLMProfile),
|
||||
Retries: b.Retries,
|
||||
Options: cloneOptions(b.Options),
|
||||
References: fileReferenceSourcesToPipeline(b.References),
|
||||
Validators: b.Validators,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,18 +313,27 @@ func LoadFileConfig(path string) (FileConfig, error) {
|
||||
}
|
||||
|
||||
func ParseFileConfigYAML(data []byte) (FileConfig, error) {
|
||||
var header struct {
|
||||
Version int `yaml:"version"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &header); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("decode yaml version header: %w", err)
|
||||
}
|
||||
if header.Version == 0 {
|
||||
return FileConfig{}, fmt.Errorf("config version is required")
|
||||
}
|
||||
if header.Version == 2 {
|
||||
return FileConfig{}, fmt.Errorf("config version 2 is no longer supported; migrate the file using the version 2-to-3 migration in docs/config.md")
|
||||
}
|
||||
if header.Version != SupportedFileConfigVersion {
|
||||
return FileConfig{}, fmt.Errorf("unsupported config version %d (supported version is %d)", header.Version, SupportedFileConfigVersion)
|
||||
}
|
||||
var fileCfg FileConfig
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||
decoder.KnownFields(true)
|
||||
if err := decoder.Decode(&fileCfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("decode yaml: %w", err)
|
||||
}
|
||||
if fileCfg.Version == 0 {
|
||||
return FileConfig{}, fmt.Errorf("config version is required")
|
||||
}
|
||||
if fileCfg.Version != SupportedFileConfigVersion {
|
||||
return FileConfig{}, fmt.Errorf("unsupported config version %d", fileCfg.Version)
|
||||
}
|
||||
return fileCfg, nil
|
||||
}
|
||||
|
||||
@@ -186,68 +346,130 @@ func (c *Config) ApplyFileConfigWithLookup(fileCfg FileConfig, lookup func(strin
|
||||
}
|
||||
|
||||
func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(string) (string, bool)) error {
|
||||
_ = lookup
|
||||
if c == nil {
|
||||
return fmt.Errorf("config must not be nil")
|
||||
}
|
||||
if fileCfg.Version != SupportedFileConfigVersion {
|
||||
return fmt.Errorf("unsupported config version %d", fileCfg.Version)
|
||||
}
|
||||
if c.LLMProfiles == nil {
|
||||
c.LLMProfiles = map[string]LLMProfile{}
|
||||
}
|
||||
if c.Pipelines == nil {
|
||||
c.Pipelines = map[string]pipeline.PipelineProfile{}
|
||||
}
|
||||
|
||||
profileIDs, rawLLMProfileIDs, err := normalizedMapKeys(fileCfg.LLMProfiles, "llm profile id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pipelineIDs, rawPipelineIDs, err := normalizedMapKeys(fileCfg.Pipelines, "pipeline id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, pipelineID := range pipelineIDs {
|
||||
filePipeline := fileCfg.Pipelines[rawPipelineIDs[pipelineID]]
|
||||
if _, _, err := normalizedMapKeys(filePipeline.Artifacts, fmt.Sprintf("pipeline %q artifact lane id", pipelineID)); err != nil {
|
||||
hasArtifacts := filePipeline.artifactsSet || filePipeline.Artifacts != nil
|
||||
hasSteps := filePipeline.stepsSet || filePipeline.Steps != nil
|
||||
if hasArtifacts && hasSteps {
|
||||
return fmt.Errorf("pipeline %q must not declare both artifacts and steps", pipelineID)
|
||||
}
|
||||
if hasSteps && len(filePipeline.Steps) == 0 {
|
||||
return fmt.Errorf("pipeline %q must declare at least one ordered step", pipelineID)
|
||||
}
|
||||
if hasSteps {
|
||||
seenSteps := make(map[string]struct{}, len(filePipeline.Steps))
|
||||
seenLanes := make(map[string]struct{})
|
||||
for index, step := range filePipeline.Steps {
|
||||
stepID := strings.TrimSpace(step.ID)
|
||||
if stepID == "" {
|
||||
return fmt.Errorf("pipeline %q step[%d] id must not be empty", pipelineID, index)
|
||||
}
|
||||
if _, ok := seenSteps[stepID]; ok {
|
||||
return fmt.Errorf("pipeline %q step id %q is duplicated after trimming", pipelineID, stepID)
|
||||
}
|
||||
seenSteps[stepID] = struct{}{}
|
||||
laneIDs, rawLaneIDs, err := normalizedMapKeys(step.Artifacts, fmt.Sprintf("pipeline %q step %q artifact lane id", pipelineID, stepID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, laneID := range laneIDs {
|
||||
if _, ok := seenLanes[laneID]; ok {
|
||||
return fmt.Errorf("pipeline %q artifact lane id %q is duplicated across steps", pipelineID, laneID)
|
||||
}
|
||||
seenLanes[laneID] = struct{}{}
|
||||
fileLane := step.Artifacts[rawLaneIDs[laneID]]
|
||||
if err := validateFileLaneReferences(pipelineID, stepID, laneID, fileLane); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateFileReferenceSources(step.References, fmt.Sprintf("pipeline %q step %q reference slot", pipelineID, stepID)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, _, err := normalizedMapKeys(filePipeline.Artifacts, fmt.Sprintf("pipeline %q artifact lane id", pipelineID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFileReferenceSources(filePipeline.References, fmt.Sprintf("pipeline %q reference slot", pipelineID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if filePipeline.Chunk != nil {
|
||||
if _, _, err := normalizedMapKeys(filePipeline.Chunk.References, fmt.Sprintf("pipeline %q chunk reference slot", pipelineID)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, _, err := normalizedMapKeys(filePipeline.Input.References, fmt.Sprintf("pipeline %q input reference slot", pipelineID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if filePipeline.Output != nil {
|
||||
if _, _, err := normalizedMapKeys(filePipeline.Output.References, fmt.Sprintf("pipeline %q output reference slot", pipelineID)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for rawLaneID, fileLane := range filePipeline.Artifacts {
|
||||
laneID := strings.TrimSpace(rawLaneID)
|
||||
if laneID == "" {
|
||||
continue
|
||||
}
|
||||
if _, _, err := normalizedMapKeys(fileLane.References, fmt.Sprintf("pipeline %q lane %q reference slot", pipelineID, laneID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := normalizedMapKeys(fileLane.Extract.References, fmt.Sprintf("pipeline %q lane %q extract reference slot", pipelineID, laneID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if fileLane.Merge != nil {
|
||||
if _, _, err := normalizedMapKeys(fileLane.Merge.References, fmt.Sprintf("pipeline %q lane %q merge reference slot", pipelineID, laneID)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if fileLane.Normalize != nil {
|
||||
if _, _, err := normalizedMapKeys(fileLane.Normalize.References, fmt.Sprintf("pipeline %q lane %q normalize reference slot", pipelineID, laneID)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, validator := range fileLane.Validators {
|
||||
if _, _, err := normalizedMapKeys(validator.References, fmt.Sprintf("pipeline %q lane %q validator[%d] reference slot", pipelineID, laneID, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, profileID := range profileIDs {
|
||||
fileProfile := fileCfg.LLMProfiles[rawLLMProfileIDs[profileID]]
|
||||
profile := c.LLMProfiles[profileID]
|
||||
if fileProfile.Provider != nil {
|
||||
profile.Provider = strings.TrimSpace(*fileProfile.Provider)
|
||||
if fileCfg.Scriptorium != nil {
|
||||
if fileCfg.Scriptorium.ProfileDir != nil {
|
||||
value := strings.TrimSpace(*fileCfg.Scriptorium.ProfileDir)
|
||||
if value == "" {
|
||||
return fmt.Errorf("scriptorium.profile_dir must not be empty when set")
|
||||
}
|
||||
if fileProfile.BaseURL != nil {
|
||||
profile.BaseURL = strings.TrimSpace(*fileProfile.BaseURL)
|
||||
c.Scriptorium.ProfileDir = value
|
||||
}
|
||||
if fileProfile.Model != nil {
|
||||
profile.Model = strings.TrimSpace(*fileProfile.Model)
|
||||
if fileCfg.Scriptorium.ProfileFile != nil {
|
||||
value := strings.TrimSpace(*fileCfg.Scriptorium.ProfileFile)
|
||||
if value == "" {
|
||||
return fmt.Errorf("scriptorium.profile_file must not be empty when set")
|
||||
}
|
||||
if fileProfile.APIKeyEnv != nil {
|
||||
apiKey, err := resolveAPIKeyEnv(*fileProfile.APIKeyEnv, lookup)
|
||||
if err != nil {
|
||||
return fmt.Errorf("llm_profiles.%s.api_key_env: %w", profileID, err)
|
||||
c.Scriptorium.ProfileFile = value
|
||||
}
|
||||
profile.APIKeyEnv = strings.TrimSpace(*fileProfile.APIKeyEnv)
|
||||
profile.APIKey = apiKey
|
||||
}
|
||||
if fileProfile.Timeout != nil {
|
||||
profile.TimeoutSeconds = fileProfile.Timeout.Seconds()
|
||||
}
|
||||
if fileProfile.MaxRetries != nil {
|
||||
profile.MaxRetries = *fileProfile.MaxRetries
|
||||
}
|
||||
if fileProfile.MaxConcurrency != nil {
|
||||
profile.MaxConcurrency = *fileProfile.MaxConcurrency
|
||||
}
|
||||
c.LLMProfiles[profileID] = profile
|
||||
}
|
||||
|
||||
for _, pipelineID := range pipelineIDs {
|
||||
filePipeline := fileCfg.Pipelines[rawPipelineIDs[pipelineID]]
|
||||
hasSteps := filePipeline.stepsSet || filePipeline.Steps != nil
|
||||
laneIDs, rawLaneIDs, err := normalizedMapKeys(filePipeline.Artifacts, fmt.Sprintf("pipeline %q artifact lane id", pipelineID))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -256,6 +478,7 @@ func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(strin
|
||||
ID: pipelineID,
|
||||
Input: filePipeline.Input.toPipelineBinding(),
|
||||
Artifacts: make(map[string]pipeline.ArtifactLaneProfile, len(filePipeline.Artifacts)),
|
||||
References: fileReferenceSourcesToPipeline(filePipeline.References),
|
||||
}
|
||||
if filePipeline.Chunk != nil {
|
||||
profile.Chunk = filePipeline.Chunk.toPipelineBinding()
|
||||
@@ -265,8 +488,11 @@ func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(strin
|
||||
}
|
||||
for _, laneID := range laneIDs {
|
||||
fileLane := filePipeline.Artifacts[rawLaneIDs[laneID]]
|
||||
extract := fileLane.Extract.toPipelineBinding()
|
||||
extract.References = mergeReferenceSources(fileReferenceSourcesToPipeline(fileLane.References), extract.References)
|
||||
lane := pipeline.ArtifactLaneProfile{
|
||||
Extract: fileLane.Extract.toPipelineBinding(),
|
||||
Extract: extract,
|
||||
References: fileReferenceSourcesToPipeline(fileLane.References),
|
||||
}
|
||||
if fileLane.Merge != nil {
|
||||
lane.Merge = fileLane.Merge.toPipelineBinding()
|
||||
@@ -282,24 +508,135 @@ func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(strin
|
||||
}
|
||||
profile.Artifacts[laneID] = lane
|
||||
}
|
||||
if hasSteps {
|
||||
profile.Artifacts = nil
|
||||
profile.Steps = make([]pipeline.PipelineStepProfile, len(filePipeline.Steps))
|
||||
for i, fileStep := range filePipeline.Steps {
|
||||
stepID := strings.TrimSpace(fileStep.ID)
|
||||
step := pipeline.PipelineStepProfile{
|
||||
ID: stepID,
|
||||
Artifacts: make(map[string]pipeline.ArtifactLaneProfile, len(fileStep.Artifacts)),
|
||||
References: fileReferenceSourcesToPipeline(fileStep.References),
|
||||
}
|
||||
stepLaneIDs, stepRawLaneIDs, err := normalizedMapKeys(fileStep.Artifacts, fmt.Sprintf("pipeline %q step %q artifact lane id", pipelineID, stepID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, laneID := range stepLaneIDs {
|
||||
fileLane := fileStep.Artifacts[stepRawLaneIDs[laneID]]
|
||||
extract := fileLane.Extract.toPipelineBinding()
|
||||
extract.References = mergeReferenceSources(fileReferenceSourcesToPipeline(fileLane.References), extract.References)
|
||||
lane := pipeline.ArtifactLaneProfile{Extract: extract, References: fileReferenceSourcesToPipeline(fileLane.References)}
|
||||
if fileLane.Merge != nil {
|
||||
lane.Merge = fileLane.Merge.toPipelineBinding()
|
||||
}
|
||||
if fileLane.Normalize != nil {
|
||||
lane.Normalize = fileLane.Normalize.toPipelineBinding()
|
||||
}
|
||||
if len(fileLane.Validators) > 0 {
|
||||
lane.Validators = make([]pipeline.ModuleBinding, len(fileLane.Validators))
|
||||
for index, validator := range fileLane.Validators {
|
||||
lane.Validators[index] = validator.toPipelineBinding()
|
||||
}
|
||||
}
|
||||
step.Artifacts[laneID] = lane
|
||||
}
|
||||
profile.Steps[i] = step
|
||||
}
|
||||
}
|
||||
c.Pipelines[pipelineID] = profile
|
||||
}
|
||||
|
||||
if fileCfg.Concurrency != nil && fileCfg.Concurrency.TotalLLM != nil {
|
||||
c.Concurrency.TotalLLM = *fileCfg.Concurrency.TotalLLM
|
||||
}
|
||||
if fileCfg.Diagnostics != nil {
|
||||
if fileCfg.Diagnostics.WorkDir != nil {
|
||||
c.Diagnostics.WorkDir = strings.TrimSpace(*fileCfg.Diagnostics.WorkDir)
|
||||
if fileCfg.Concurrency != nil && fileCfg.Concurrency.StageWorkers != nil {
|
||||
workers, configured, err := normalizeStageWorkers(fileCfg.Concurrency.StageWorkers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fileCfg.Diagnostics.Retention != nil {
|
||||
c.Diagnostics.Retention = diagnostics.RetentionMode(strings.TrimSpace(*fileCfg.Diagnostics.Retention))
|
||||
c.Concurrency.StageWorkers = workers
|
||||
c.Concurrency.extractWorkersConfigured = configured
|
||||
}
|
||||
c.Concurrency.recomputeStageWorkerDefaults()
|
||||
if fileCfg.Output != nil && fileCfg.Output.Directory != nil {
|
||||
c.Output.Directory = strings.TrimSpace(*fileCfg.Output.Directory)
|
||||
if c.Output.Directory == "" {
|
||||
return fmt.Errorf("output.directory must not be empty")
|
||||
}
|
||||
if strings.ContainsRune(c.Output.Directory, '\x00') {
|
||||
return fmt.Errorf("output.directory must not contain NUL")
|
||||
}
|
||||
}
|
||||
if fileCfg.Cache != nil {
|
||||
if fileCfg.Cache.ChunkPlans != nil {
|
||||
if fileCfg.Cache.ChunkPlans.Mode != nil {
|
||||
mode, err := pipeline.ParseChunkCacheMode(*fileCfg.Cache.ChunkPlans.Mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cache.chunk_plans.mode: %w", err)
|
||||
}
|
||||
c.Cache.ChunkPlans.Mode = mode
|
||||
}
|
||||
if fileCfg.Cache.ChunkPlans.Directory != nil {
|
||||
c.Cache.ChunkPlans.Directory = cleanOptionalPath(*fileCfg.Cache.ChunkPlans.Directory)
|
||||
if strings.ContainsRune(c.Cache.ChunkPlans.Directory, '\x00') {
|
||||
return fmt.Errorf("cache.chunk_plans.directory must not contain NUL")
|
||||
}
|
||||
}
|
||||
}
|
||||
if fileCfg.Cache.Checkpoints != nil {
|
||||
if fileCfg.Cache.Checkpoints.Enabled != nil {
|
||||
c.Cache.Checkpoints.Enabled = *fileCfg.Cache.Checkpoints.Enabled
|
||||
}
|
||||
if fileCfg.Cache.Checkpoints.Directory != nil {
|
||||
c.Cache.Checkpoints.Directory = cleanOptionalPath(*fileCfg.Cache.Checkpoints.Directory)
|
||||
if strings.ContainsRune(c.Cache.Checkpoints.Directory, '\x00') {
|
||||
return fmt.Errorf("cache.checkpoints.directory must not contain NUL")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if fileCfg.Debug != nil && fileCfg.Debug.Directory != nil {
|
||||
c.Debug.Directory = strings.TrimSpace(*fileCfg.Debug.Directory)
|
||||
if c.Debug.Directory == "" {
|
||||
return fmt.Errorf("debug.directory must not be empty")
|
||||
}
|
||||
if strings.ContainsRune(c.Debug.Directory, '\x00') {
|
||||
return fmt.Errorf("debug.directory must not contain NUL")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanOptionalPath(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Clean(value)
|
||||
}
|
||||
|
||||
func normalizeStageWorkers(values map[string]int) (map[string]int, bool, error) {
|
||||
workers := make(map[string]int, len(values))
|
||||
configured := false
|
||||
for rawKey, value := range values {
|
||||
key := strings.TrimSpace(rawKey)
|
||||
if key == "" {
|
||||
return nil, false, fmt.Errorf("concurrency.stage_workers key must not be empty")
|
||||
}
|
||||
if key != "extract" {
|
||||
return nil, false, fmt.Errorf("concurrency.stage_workers key %q is not supported", rawKey)
|
||||
}
|
||||
if _, exists := workers[key]; exists {
|
||||
return nil, false, fmt.Errorf("concurrency.stage_workers key %q is duplicated after trimming", key)
|
||||
}
|
||||
workers[key] = value
|
||||
configured = true
|
||||
}
|
||||
return workers, configured, nil
|
||||
}
|
||||
|
||||
func normalizedMapKeys[T any](values map[string]T, keyName string) ([]string, map[string]string, error) {
|
||||
keys := make([]string, 0, len(values))
|
||||
rawByNormalized := make(map[string]string, len(values))
|
||||
@@ -318,19 +655,79 @@ func normalizedMapKeys[T any](values map[string]T, keyName string) ([]string, ma
|
||||
return keys, rawByNormalized, nil
|
||||
}
|
||||
|
||||
func resolveAPIKeyEnv(envName string, lookup func(string) (string, bool)) (string, error) {
|
||||
name := strings.TrimSpace(envName)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("must not be empty")
|
||||
func validateFileReferenceSources(values map[string]fileReferenceSource, context string) error {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for rawSlot, source := range values {
|
||||
slot := strings.TrimSpace(rawSlot)
|
||||
if slot == "" {
|
||||
return fmt.Errorf("%s must not be empty", context)
|
||||
}
|
||||
if !envVarNamePattern.MatchString(name) {
|
||||
return "", fmt.Errorf("must be an environment variable name")
|
||||
if _, ok := seen[slot]; ok {
|
||||
return fmt.Errorf("%s %q is duplicated after trimming", context, slot)
|
||||
}
|
||||
value, ok := lookup(name)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is not set", name)
|
||||
seen[slot] = struct{}{}
|
||||
if source.artifact != nil {
|
||||
if strings.TrimSpace(source.artifact.Step) == "" || strings.TrimSpace(source.artifact.Lane) == "" {
|
||||
return fmt.Errorf("%s %q artifact selector step and lane must not be empty", context, slot)
|
||||
}
|
||||
return value, nil
|
||||
if strings.TrimSpace(source.path) != "" {
|
||||
return fmt.Errorf("%s %q must contain either an external path or artifact selector", context, slot)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(source.path) == "" {
|
||||
return fmt.Errorf("%s %q source must not be empty", context, slot)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFileLaneReferences(pipelineID, stepID, laneID string, lane FileArtifactLaneProfile) error {
|
||||
prefix := fmt.Sprintf("pipeline %q step %q lane %q", pipelineID, stepID, laneID)
|
||||
references := []struct {
|
||||
label string
|
||||
values map[string]fileReferenceSource
|
||||
}{
|
||||
{label: "reference slot", values: lane.References},
|
||||
{label: "extract reference slot", values: lane.Extract.References},
|
||||
}
|
||||
if lane.Merge != nil {
|
||||
references = append(references, struct {
|
||||
label string
|
||||
values map[string]fileReferenceSource
|
||||
}{label: "merge reference slot", values: lane.Merge.References})
|
||||
}
|
||||
if lane.Normalize != nil {
|
||||
references = append(references, struct {
|
||||
label string
|
||||
values map[string]fileReferenceSource
|
||||
}{label: "normalize reference slot", values: lane.Normalize.References})
|
||||
}
|
||||
for _, item := range references {
|
||||
if err := validateFileReferenceSources(item.values, prefix+" "+item.label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for index, validator := range lane.Validators {
|
||||
if err := validateFileReferenceSources(validator.References, fmt.Sprintf("%s validator[%d] reference slot", prefix, index)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeReferenceSources(base map[string]pipeline.ReferenceSource, override map[string]pipeline.ReferenceSource) map[string]pipeline.ReferenceSource {
|
||||
if len(base) == 0 && len(override) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]pipeline.ReferenceSource, len(base)+len(override))
|
||||
for key, value := range base {
|
||||
out[key] = value
|
||||
}
|
||||
for key, value := range override {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeOptions(options map[string]any) map[string]any {
|
||||
|
||||
470
internal/core/config/file_config_contract_test.go
Normal file
470
internal/core/config/file_config_contract_test.go
Normal file
@@ -0,0 +1,470 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestDefaultReturnsDocumentedValuesAndIndependentMaps(t *testing.T) {
|
||||
first := Default()
|
||||
if first.Concurrency.TotalLLM != 1 || first.Concurrency.StageWorkers["extract"] != 1 {
|
||||
t.Fatalf("concurrency defaults = %#v", first.Concurrency)
|
||||
}
|
||||
if first.Output.Directory != "./notarius-output" || first.Debug.Directory != "./notarius-debug" {
|
||||
t.Fatalf("output/debug defaults = %#v, %#v", first.Output, first.Debug)
|
||||
}
|
||||
if first.Cache.ChunkPlans.Mode != pipeline.ChunkCacheAuto || first.Cache.ChunkPlans.Directory != "" || first.Cache.Checkpoints.Enabled || first.Cache.Checkpoints.Directory != "" {
|
||||
t.Fatalf("cache defaults = %#v", first.Cache)
|
||||
}
|
||||
if len(first.Pipelines) != 0 {
|
||||
t.Fatalf("pipeline defaults = %#v", first.Pipelines)
|
||||
}
|
||||
|
||||
first.Concurrency.StageWorkers["extract"] = 99
|
||||
first.Concurrency.StageWorkers["other"] = 100
|
||||
first.Pipelines["changed"] = pipeline.PipelineProfile{}
|
||||
second := Default()
|
||||
if second.Concurrency.StageWorkers["extract"] != 1 || len(second.Concurrency.StageWorkers) != 1 || len(second.Pipelines) != 0 {
|
||||
t.Fatalf("Default() returned state shared with an earlier result: %#v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigMinimalVersion3AppliesOverDefaults(t *testing.T) {
|
||||
file := parseFileConfig(t, "version: 3\n")
|
||||
cfg := Default()
|
||||
if err := cfg.ApplyFileConfig(file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Output.Directory != "./notarius-output" || cfg.Debug.Directory != "./notarius-debug" || cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheAuto {
|
||||
t.Fatalf("minimal file changed unrelated defaults: %#v", cfg)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != 1 || cfg.Concurrency.StageWorkers["extract"] != 1 || len(cfg.Pipelines) != 0 {
|
||||
t.Fatalf("minimal file did not retain defaults: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigMissingVersionIsReportedBeforeFieldDecoding(t *testing.T) {
|
||||
_, err := ParseFileConfigYAML([]byte("workspace:\n directory: /tmp/old\n"))
|
||||
if err == nil || !strings.Contains(err.Error(), "config version is required") {
|
||||
t.Fatalf("missing version error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigRejectsUnknownCurrentAndRemovedFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "removed diagnostics",
|
||||
yaml: "version: 3\ndiagnostics: {}\n",
|
||||
want: "field diagnostics not found",
|
||||
},
|
||||
{
|
||||
name: "removed llm profiles",
|
||||
yaml: "version: 3\nllm_profiles: {}\n",
|
||||
want: "field llm_profiles not found",
|
||||
},
|
||||
{
|
||||
name: "version 2 migration",
|
||||
yaml: "version: 2\nworkspace:\n directory: /tmp/old\n",
|
||||
want: "version 2-to-3 migration",
|
||||
},
|
||||
{
|
||||
name: "pipeline field",
|
||||
yaml: "version: 3\npipelines:\n main:\n unknown: true\n",
|
||||
want: "field unknown not found",
|
||||
},
|
||||
{
|
||||
name: "lane field",
|
||||
yaml: "version: 3\npipelines:\n main:\n artifacts:\n spells:\n unknown: true\n",
|
||||
want: "field unknown not found",
|
||||
},
|
||||
{
|
||||
name: "module binding field",
|
||||
yaml: "version: 3\npipelines:\n main:\n input:\n module: seriatim\n unknown: true\n",
|
||||
want: "field unknown not found in module binding",
|
||||
},
|
||||
{
|
||||
name: "checkpoint field",
|
||||
yaml: "version: 3\ncache:\n checkpoints:\n unknown: true\n",
|
||||
want: "field unknown not found",
|
||||
},
|
||||
{
|
||||
name: "checkpoint enabled type",
|
||||
yaml: "version: 3\ncache:\n checkpoints:\n enabled: definitely\n",
|
||||
want: "cannot unmarshal",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ParseFileConfigYAML([]byte(tt.yaml))
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error = %v, want context %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigModuleBindingsPreserveFormsAndValidatorPresence(t *testing.T) {
|
||||
cfg := applyFileConfig(t, `version: 3
|
||||
pipelines:
|
||||
main:
|
||||
input: seriatim
|
||||
chunk:
|
||||
module: generic
|
||||
llm_profile: chunk-profile
|
||||
retries: 2
|
||||
options:
|
||||
max_units: 25
|
||||
references:
|
||||
glossary: ./glossary.md
|
||||
validators: []
|
||||
artifacts:
|
||||
spells:
|
||||
extract:
|
||||
module: dnd/spells
|
||||
options:
|
||||
nested:
|
||||
enabled: true
|
||||
merge: appendorder
|
||||
normalize: noop
|
||||
`)
|
||||
profile := cfg.Pipelines["main"]
|
||||
if profile.Input.Module != "seriatim" || profile.Input.Validators.Set {
|
||||
t.Fatalf("shorthand binding = %#v", profile.Input)
|
||||
}
|
||||
if profile.Chunk.Module != "generic" || profile.Chunk.LLMProfile != "chunk-profile" || profile.Chunk.Retries != 2 ||
|
||||
!reflect.DeepEqual(profile.Chunk.Options, map[string]any{"max_units": 25}) ||
|
||||
!reflect.DeepEqual(profile.Chunk.References, pipeline.ExternalReferenceMap(map[string]string{"glossary": "./glossary.md"})) {
|
||||
t.Fatalf("object binding = %#v", profile.Chunk)
|
||||
}
|
||||
if !profile.Chunk.Validators.Set || len(profile.Chunk.Validators.Validators) != 0 {
|
||||
t.Fatalf("explicit empty validators = %#v", profile.Chunk.Validators)
|
||||
}
|
||||
if profile.Artifacts["spells"].Extract.Module != "dnd/spells" ||
|
||||
!reflect.DeepEqual(profile.Artifacts["spells"].Extract.Options, map[string]any{
|
||||
"nested": map[string]any{"enabled": true},
|
||||
}) {
|
||||
t.Fatalf("extract binding = %#v", profile.Artifacts["spells"].Extract)
|
||||
}
|
||||
if profile.Artifacts["spells"].Merge.Module != "appendorder" || profile.Artifacts["spells"].Normalize.Module != "noop" {
|
||||
t.Fatalf("stage shorthand bindings = %#v", profile.Artifacts["spells"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigReferencePrecedenceIsRetained(t *testing.T) {
|
||||
cfg := applyFileConfig(t, `version: 3
|
||||
pipelines:
|
||||
main:
|
||||
input: seriatim
|
||||
references:
|
||||
pipeline-only: ./pipeline.txt
|
||||
shared: ./pipeline-shared.txt
|
||||
chunk:
|
||||
module: generic
|
||||
references:
|
||||
chunk-only: ./chunk.txt
|
||||
artifacts:
|
||||
spells:
|
||||
references:
|
||||
lane-only: ./lane.txt
|
||||
shared: ./lane-shared.txt
|
||||
overridden: ./lane.txt
|
||||
extract:
|
||||
module: dnd/spells
|
||||
references:
|
||||
extract-only: ./extract.txt
|
||||
overridden: ./extract-overridden.txt
|
||||
merge:
|
||||
module: appendorder
|
||||
references:
|
||||
merge-only: ./merge.txt
|
||||
normalize:
|
||||
module: noop
|
||||
references:
|
||||
normalize-only: ./normalize.txt
|
||||
`)
|
||||
profile := cfg.Pipelines["main"]
|
||||
if !reflect.DeepEqual(profile.References, pipeline.ExternalReferenceMap(map[string]string{
|
||||
"pipeline-only": "./pipeline.txt",
|
||||
"shared": "./pipeline-shared.txt",
|
||||
})) {
|
||||
t.Fatalf("pipeline references = %#v", profile.References)
|
||||
}
|
||||
if !reflect.DeepEqual(profile.Chunk.References, pipeline.ExternalReferenceMap(map[string]string{"chunk-only": "./chunk.txt"})) {
|
||||
t.Fatalf("chunk references = %#v", profile.Chunk.References)
|
||||
}
|
||||
lane := profile.Artifacts["spells"]
|
||||
if !reflect.DeepEqual(lane.References, pipeline.ExternalReferenceMap(map[string]string{
|
||||
"lane-only": "./lane.txt",
|
||||
"shared": "./lane-shared.txt",
|
||||
"overridden": "./lane.txt",
|
||||
})) {
|
||||
t.Fatalf("lane compatibility references = %#v", lane.References)
|
||||
}
|
||||
if !reflect.DeepEqual(lane.Extract.References, pipeline.ExternalReferenceMap(map[string]string{
|
||||
"lane-only": "./lane.txt",
|
||||
"shared": "./lane-shared.txt",
|
||||
"overridden": "./extract-overridden.txt",
|
||||
"extract-only": "./extract.txt",
|
||||
})) {
|
||||
t.Fatalf("extract references = %#v", lane.Extract.References)
|
||||
}
|
||||
if !reflect.DeepEqual(lane.Merge.References, pipeline.ExternalReferenceMap(map[string]string{"merge-only": "./merge.txt"})) ||
|
||||
!reflect.DeepEqual(lane.Normalize.References, pipeline.ExternalReferenceMap(map[string]string{"normalize-only": "./normalize.txt"})) {
|
||||
t.Fatalf("merge/normalize references = %#v, %#v", lane.Merge.References, lane.Normalize.References)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigStageLocalValidatorsPreserveOrderAndFields(t *testing.T) {
|
||||
cfg := applyFileConfig(t, `version: 3
|
||||
pipelines:
|
||||
main:
|
||||
input: seriatim
|
||||
chunk:
|
||||
module: generic
|
||||
validators:
|
||||
- generic/always_accept
|
||||
- module: generic/valid_json
|
||||
llm_profile: validator-profile
|
||||
options:
|
||||
schema: compact
|
||||
artifacts:
|
||||
spells:
|
||||
extract:
|
||||
module: dnd/spells
|
||||
validators:
|
||||
- module: extract/dnd/spells/shape
|
||||
options:
|
||||
strict: true
|
||||
merge:
|
||||
module: appendorder
|
||||
validators:
|
||||
- generic/always_accept
|
||||
normalize:
|
||||
module: noop
|
||||
validators:
|
||||
- module: generic/valid_json
|
||||
options:
|
||||
mode: normalized
|
||||
`)
|
||||
profile := cfg.Pipelines["main"]
|
||||
chunkValidators := profile.Chunk.Validators.Validators
|
||||
if !profile.Chunk.Validators.Set || len(chunkValidators) != 2 || chunkValidators[0].Module != "generic/always_accept" ||
|
||||
chunkValidators[1].Module != "generic/valid_json" || chunkValidators[1].LLMProfile != "validator-profile" ||
|
||||
!reflect.DeepEqual(chunkValidators[1].Options, map[string]any{"schema": "compact"}) {
|
||||
t.Fatalf("chunk validators = %#v", profile.Chunk.Validators)
|
||||
}
|
||||
lane := profile.Artifacts["spells"]
|
||||
if len(lane.Extract.Validators.Validators) != 1 || lane.Extract.Validators.Validators[0].Module != "extract/dnd/spells/shape" ||
|
||||
!reflect.DeepEqual(lane.Extract.Validators.Validators[0].Options, map[string]any{"strict": true}) {
|
||||
t.Fatalf("extract validators = %#v", lane.Extract.Validators)
|
||||
}
|
||||
if len(lane.Merge.Validators.Validators) != 1 || lane.Merge.Validators.Validators[0].Module != "generic/always_accept" {
|
||||
t.Fatalf("merge validators = %#v", lane.Merge.Validators)
|
||||
}
|
||||
if len(lane.Normalize.Validators.Validators) != 1 || lane.Normalize.Validators.Validators[0].Module != "generic/valid_json" ||
|
||||
!reflect.DeepEqual(lane.Normalize.Validators.Validators[0].Options, map[string]any{"mode": "normalized"}) {
|
||||
t.Fatalf("normalize validators = %#v", lane.Normalize.Validators)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigStateSectionsApplyIndependently(t *testing.T) {
|
||||
cfg := applyFileConfig(t, `version: 3
|
||||
scriptorium:
|
||||
profile_dir: ./profiles
|
||||
concurrency:
|
||||
total_llm: 7
|
||||
output:
|
||||
directory: ./output
|
||||
cache:
|
||||
chunk_plans:
|
||||
directory: ./plans
|
||||
mode: bypass
|
||||
checkpoints:
|
||||
enabled: true
|
||||
directory: ./checkpoints
|
||||
debug:
|
||||
directory: ./debug
|
||||
`)
|
||||
if cfg.Scriptorium.ProfileDir != "./profiles" || cfg.Scriptorium.ProfileFile != "" {
|
||||
t.Fatalf("scriptorium = %#v", cfg.Scriptorium)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != 7 || cfg.Concurrency.StageWorkers["extract"] != 7 {
|
||||
t.Fatalf("concurrency = %#v", cfg.Concurrency)
|
||||
}
|
||||
if cfg.Output.Directory != "./output" || cfg.Cache.ChunkPlans.Directory != "plans" || cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheBypass ||
|
||||
!cfg.Cache.Checkpoints.Enabled || cfg.Cache.Checkpoints.Directory != "checkpoints" || cfg.Debug.Directory != "./debug" {
|
||||
t.Fatalf("state sections = %#v, %#v, %#v, %#v", cfg.Output, cfg.Cache, cfg.Debug, cfg.Scriptorium)
|
||||
}
|
||||
if cfg.Output.Directory == cfg.Cache.ChunkPlans.Directory || cfg.Cache.ChunkPlans.Directory == cfg.Cache.Checkpoints.Directory || cfg.Cache.Checkpoints.Directory == cfg.Debug.Directory {
|
||||
t.Fatal("state roots were coupled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigCheckpointEnabledCanBeExplicitlyDisabled(t *testing.T) {
|
||||
cfg := applyFileConfig(t, "version: 3\ncache:\n checkpoints:\n enabled: true\n")
|
||||
if !cfg.Cache.Checkpoints.Enabled || !cloneConfig(cfg).Cache.Checkpoints.Enabled {
|
||||
t.Fatalf("enabled checkpoint config was not retained: %#v", cfg.Cache.Checkpoints)
|
||||
}
|
||||
file := parseFileConfig(t, "version: 3\ncache:\n checkpoints:\n enabled: false\n")
|
||||
if err := cfg.ApplyFileConfig(file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Cache.Checkpoints.Enabled {
|
||||
t.Fatalf("explicit false checkpoint config was not applied: %#v", cfg.Cache.Checkpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigRejectsTrimmedKeyCollisions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "pipeline ids",
|
||||
yaml: "version: 3\npipelines:\n main: {}\n ' main ': {}\n",
|
||||
want: "pipeline id \"main\" is duplicated after trimming",
|
||||
},
|
||||
{
|
||||
name: "lane ids",
|
||||
yaml: "version: 3\npipelines:\n main:\n artifacts:\n spells: {}\n ' spells ': {}\n",
|
||||
want: "artifact lane id \"spells\" is duplicated after trimming",
|
||||
},
|
||||
{
|
||||
name: "reference slots",
|
||||
yaml: "version: 3\npipelines:\n main:\n references:\n slot: ./one.txt\n ' slot ': ./two.txt\n",
|
||||
want: "reference slot \"slot\" is duplicated after trimming",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
file := parseFileConfig(t, tt.yaml)
|
||||
cfg := Default()
|
||||
err := cfg.ApplyFileConfig(file)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("ApplyFileConfig() error = %v, want context %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigParsesOrderedStepsAndReferenceSources(t *testing.T) {
|
||||
file := parseFileConfig(t, `version: 3
|
||||
pipelines:
|
||||
session:
|
||||
input: seriatim
|
||||
steps:
|
||||
- id: identify-npcs
|
||||
artifacts:
|
||||
npcs:
|
||||
extract: dnd/npcs
|
||||
- id: grounded-events
|
||||
references:
|
||||
npcs:
|
||||
artifact:
|
||||
step: identify-npcs
|
||||
lane: npcs
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
`)
|
||||
cfg := Default()
|
||||
if err := cfg.ApplyFileConfig(file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := cfg.Pipelines["session"]
|
||||
if len(profile.Steps) != 2 || profile.Steps[0].ID != "identify-npcs" || profile.Steps[1].ID != "grounded-events" {
|
||||
t.Fatalf("steps = %#v", profile.Steps)
|
||||
}
|
||||
source := profile.Steps[1].References["npcs"]
|
||||
if source.Artifact == nil || source.Artifact.Step != "identify-npcs" || source.Artifact.Lane != "npcs" {
|
||||
t.Fatalf("generated source = %#v", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigRejectsAmbiguousReferenceSourceForms(t *testing.T) {
|
||||
for _, source := range []string{
|
||||
"artifact: {step: a, lane: b, extra: c}",
|
||||
"artifact: {step: 1, lane: b}",
|
||||
"1",
|
||||
} {
|
||||
_, err := ParseFileConfigYAML([]byte("version: 3\npipelines:\n p:\n input: text\n references:\n slot: " + source + "\n"))
|
||||
if err == nil {
|
||||
t.Fatalf("ParseFileConfigYAML(%q) error = nil", source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigRejectsEmptyAndAmbiguousPipelineShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty steps",
|
||||
yaml: "version: 3\npipelines:\n p:\n input: text\n steps: []\n",
|
||||
want: "at least one ordered step",
|
||||
},
|
||||
{
|
||||
name: "both forms",
|
||||
yaml: "version: 3\npipelines:\n p:\n input: text\n artifacts: {}\n steps: []\n",
|
||||
want: "both artifacts and steps",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
file := parseFileConfig(t, tt.yaml)
|
||||
cfg := Default()
|
||||
err := cfg.ApplyFileConfig(file)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("ApplyFileConfig() error = %v, want context %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileConfigReportsPathAndOperationContext(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missing := filepath.Join(dir, "missing.yml")
|
||||
_, err := LoadFileConfig(missing)
|
||||
if err == nil || !strings.Contains(err.Error(), "read config file") || !strings.Contains(err.Error(), missing) {
|
||||
t.Fatalf("missing-file error = %v", err)
|
||||
}
|
||||
|
||||
malformed := filepath.Join(dir, "malformed.yml")
|
||||
if err := os.WriteFile(malformed, []byte("version: [\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = LoadFileConfig(malformed)
|
||||
if err == nil || !strings.Contains(err.Error(), "parse config file") || !strings.Contains(err.Error(), malformed) {
|
||||
t.Fatalf("malformed-file error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseFileConfig(t *testing.T, source string) FileConfig {
|
||||
t.Helper()
|
||||
file, err := ParseFileConfigYAML([]byte(source))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML() error = %v", err)
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
func applyFileConfig(t *testing.T, source string) Config {
|
||||
t.Helper()
|
||||
cfg := Default()
|
||||
if err := cfg.ApplyFileConfig(parseFileConfig(t, source)); err != nil {
|
||||
t.Fatalf("ApplyFileConfig() error = %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
|
||||
)
|
||||
|
||||
func TestParseMinimalValidConfig(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(`
|
||||
version: 1
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML: %v", err)
|
||||
}
|
||||
if fileCfg.Version != SupportedFileConfigVersion {
|
||||
t.Fatalf("unexpected version: %d", fileCfg.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, []byte("version: 1\n"), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
fileCfg, err := LoadFileConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFileConfig: %v", err)
|
||||
}
|
||||
if fileCfg.Version != SupportedFileConfigVersion {
|
||||
t.Fatalf("unexpected version: %d", fileCfg.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigRejectsUnknownYAMLFields(t *testing.T) {
|
||||
_, err := ParseFileConfigYAML([]byte(`
|
||||
version: 1
|
||||
unexpected: true
|
||||
`))
|
||||
if err == nil || !strings.Contains(err.Error(), "field unexpected not found") {
|
||||
t.Fatalf("expected unknown field error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigRejectsUnknownModuleBindingFields(t *testing.T) {
|
||||
_, err := ParseFileConfigYAML([]byte(`
|
||||
version: 1
|
||||
pipelines:
|
||||
example:
|
||||
input:
|
||||
module: fake/input
|
||||
unexpected: true
|
||||
artifacts:
|
||||
events:
|
||||
extract: fake/extract
|
||||
`))
|
||||
if err == nil || !strings.Contains(err.Error(), "field unexpected not found") {
|
||||
t.Fatalf("expected unknown binding field error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigRejectsMissingAndUnsupportedVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
want string
|
||||
}{
|
||||
{name: "missing", data: `llm_profiles: {}`, want: "version is required"},
|
||||
{name: "unsupported", data: `version: 2`, want: "unsupported config version"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := ParseFileConfigYAML([]byte(tc.data))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigModuleBindingForms(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 1
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
chunk:
|
||||
module: generic
|
||||
options:
|
||||
size: 10
|
||||
flags:
|
||||
- alpha
|
||||
nested:
|
||||
enabled: true
|
||||
artifacts:
|
||||
events:
|
||||
extract:
|
||||
module: fake/extract
|
||||
llm_profile: fast
|
||||
options:
|
||||
temperature: 0
|
||||
merge: appendorder
|
||||
normalize:
|
||||
module: noop
|
||||
output: json
|
||||
`)
|
||||
|
||||
profile := cfg.Pipelines["example"]
|
||||
if profile.Input.Module != "fake/input" {
|
||||
t.Fatalf("unexpected input binding: %+v", profile.Input)
|
||||
}
|
||||
if profile.Chunk.Module != "generic" {
|
||||
t.Fatalf("unexpected chunk binding: %+v", profile.Chunk)
|
||||
}
|
||||
if profile.Chunk.Options["size"] != 10 {
|
||||
t.Fatalf("expected chunk options to preserve scalar, got %#v", profile.Chunk.Options)
|
||||
}
|
||||
if !reflect.DeepEqual(profile.Chunk.Options["flags"], []any{"alpha"}) {
|
||||
t.Fatalf("expected list option, got %#v", profile.Chunk.Options["flags"])
|
||||
}
|
||||
nested, ok := profile.Chunk.Options["nested"].(map[string]any)
|
||||
if !ok || nested["enabled"] != true {
|
||||
t.Fatalf("expected nested map option, got %#v", profile.Chunk.Options["nested"])
|
||||
}
|
||||
|
||||
lane := profile.Artifacts["events"]
|
||||
if lane.Extract.Module != "fake/extract" || lane.Extract.LLMProfile != "fast" {
|
||||
t.Fatalf("unexpected extract binding: %+v", lane.Extract)
|
||||
}
|
||||
if lane.Extract.Options["temperature"] != 0 {
|
||||
t.Fatalf("expected object options, got %#v", lane.Extract.Options)
|
||||
}
|
||||
if lane.Merge.Module != "appendorder" || lane.Normalize.Module != "noop" {
|
||||
t.Fatalf("unexpected lane defaults: %+v", lane)
|
||||
}
|
||||
if profile.Output.Module != "json" {
|
||||
t.Fatalf("unexpected output binding: %+v", profile.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigValidatorMixedBindingForms(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 1
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
artifacts:
|
||||
events:
|
||||
extract: fake/extract
|
||||
validators:
|
||||
- fake/validator
|
||||
- module: fake/llm-validator
|
||||
llm_profile: careful
|
||||
options:
|
||||
threshold: 0.7
|
||||
`)
|
||||
|
||||
validators := cfg.Pipelines["example"].Artifacts["events"].Validators
|
||||
if len(validators) != 2 {
|
||||
t.Fatalf("expected two validators, got %d", len(validators))
|
||||
}
|
||||
if validators[0].Module != "fake/validator" {
|
||||
t.Fatalf("unexpected shorthand validator: %+v", validators[0])
|
||||
}
|
||||
if validators[1].Module != "fake/llm-validator" || validators[1].LLMProfile != "careful" {
|
||||
t.Fatalf("unexpected object validator: %+v", validators[1])
|
||||
}
|
||||
if validators[1].Options["threshold"] != 0.7 {
|
||||
t.Fatalf("unexpected validator options: %#v", validators[1].Options)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigDurationParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want int
|
||||
}{
|
||||
{name: "integer seconds", raw: "600", want: 600},
|
||||
{name: "duration string", raw: "10m", want: 600},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 1
|
||||
llm_profiles:
|
||||
default:
|
||||
timeout: `+tc.raw+`
|
||||
`)
|
||||
if got := cfg.LLMProfiles["default"].TimeoutSeconds; got != tc.want {
|
||||
t.Fatalf("TimeoutSeconds = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigRejectsSubsecondDuration(t *testing.T) {
|
||||
_, err := ParseFileConfigYAML([]byte(`
|
||||
version: 1
|
||||
llm_profiles:
|
||||
default:
|
||||
timeout: 1500ms
|
||||
`))
|
||||
if err == nil || !strings.Contains(err.Error(), "whole seconds") {
|
||||
t.Fatalf("expected whole-seconds duration error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigResolvesAPIKeyEnv(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(`
|
||||
version: 1
|
||||
llm_profiles:
|
||||
default:
|
||||
api_key_env: NOTARIUS_TEST_API_KEY
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML: %v", err)
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
if err := cfg.applyFileConfigWithLookup(fileCfg, mapLookup(map[string]string{"NOTARIUS_TEST_API_KEY": "secret"})); err != nil {
|
||||
t.Fatalf("ApplyFileConfig: %v", err)
|
||||
}
|
||||
profile := cfg.LLMProfiles["default"]
|
||||
if profile.APIKeyEnv != "NOTARIUS_TEST_API_KEY" || profile.APIKey != "secret" {
|
||||
t.Fatalf("unexpected resolved API key: %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigRejectsDuplicateTrimmedLLMProfileIDs(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(`
|
||||
version: 1
|
||||
llm_profiles:
|
||||
default:
|
||||
model: first
|
||||
" default ":
|
||||
model: second
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML: %v", err)
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
|
||||
if err == nil || !strings.Contains(err.Error(), "llm profile id") || !strings.Contains(err.Error(), "duplicated") {
|
||||
t.Fatalf("expected duplicate LLM profile ID error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigRejectsDuplicateTrimmedPipelineIDs(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(`
|
||||
version: 1
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
" example ":
|
||||
input: fake/other-input
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML: %v", err)
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
|
||||
if err == nil || !strings.Contains(err.Error(), "pipeline id") || !strings.Contains(err.Error(), "duplicated") {
|
||||
t.Fatalf("expected duplicate pipeline ID error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigRejectsDuplicateTrimmedArtifactLaneIDs(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(`
|
||||
version: 1
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
artifacts:
|
||||
events:
|
||||
extract: fake/extract
|
||||
" events ":
|
||||
extract: fake/other-extract
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML: %v", err)
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
|
||||
if err == nil || !strings.Contains(err.Error(), `pipeline "example" artifact lane id`) || !strings.Contains(err.Error(), "duplicated") {
|
||||
t.Fatalf("expected duplicate artifact lane ID error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigAllowsRetryOnlyLLMProfile(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 1
|
||||
llm_profiles:
|
||||
retry-only:
|
||||
max_retries: 3
|
||||
`)
|
||||
|
||||
profile := cfg.LLMProfiles["retry-only"]
|
||||
if profile.MaxRetries != 3 {
|
||||
t.Fatalf("unexpected max retries: %d", profile.MaxRetries)
|
||||
}
|
||||
if profile.TimeoutSeconds != 0 {
|
||||
t.Fatalf("expected unset timeout, got %d", profile.TimeoutSeconds)
|
||||
}
|
||||
if profile.MaxConcurrency != 0 {
|
||||
t.Fatalf("expected unset max concurrency, got %d", profile.MaxConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigRejectsInvalidAPIKeyEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
want string
|
||||
}{
|
||||
{name: "invalid name", env: "NOTARIUS-KEY", want: "environment variable name"},
|
||||
{name: "not set", env: "NOTARIUS_TEST_API_KEY", want: "is not set"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(`
|
||||
version: 1
|
||||
llm_profiles:
|
||||
default:
|
||||
api_key_env: ` + tc.env + `
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML: %v", err)
|
||||
}
|
||||
cfg := Default()
|
||||
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigOperationalSections(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 1
|
||||
concurrency:
|
||||
total_llm: 4
|
||||
diagnostics:
|
||||
work_dir: /tmp/notarius-test
|
||||
retention: always
|
||||
`)
|
||||
|
||||
if cfg.Concurrency.TotalLLM != 4 {
|
||||
t.Fatalf("unexpected total concurrency: %d", cfg.Concurrency.TotalLLM)
|
||||
}
|
||||
if cfg.Diagnostics.WorkDir != "/tmp/notarius-test" {
|
||||
t.Fatalf("unexpected work dir: %q", cfg.Diagnostics.WorkDir)
|
||||
}
|
||||
if cfg.Diagnostics.Retention != diagnostics.RetentionAlways {
|
||||
t.Fatalf("unexpected retention: %q", cfg.Diagnostics.Retention)
|
||||
}
|
||||
}
|
||||
|
||||
func parseAndApplyConfig(t *testing.T, raw string) Config {
|
||||
t.Helper()
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML: %v", err)
|
||||
}
|
||||
cfg := Default()
|
||||
if err := cfg.applyFileConfigWithLookup(fileCfg, emptyLookup); err != nil {
|
||||
t.Fatalf("ApplyFileConfig: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func emptyLookup(string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func mapLookup(values map[string]string) func(string) (string, bool) {
|
||||
return func(key string) (string, bool) {
|
||||
value, ok := values[key]
|
||||
return value, ok
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,73 @@
|
||||
package config
|
||||
|
||||
import "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
const redactedSecret = "[REDACTED]"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func (c Config) Redacted() Config {
|
||||
redacted := cloneConfig(c)
|
||||
for id, profile := range redacted.LLMProfiles {
|
||||
if profile.APIKey != "" {
|
||||
profile.APIKey = redactedSecret
|
||||
}
|
||||
redacted.LLMProfiles[id] = profile
|
||||
}
|
||||
return redacted
|
||||
return redactConfig(cloneConfig(c))
|
||||
}
|
||||
|
||||
func (c Config) RedactedDiagnosticsPayload() any {
|
||||
func (c Config) RedactedSummaryPayload() any {
|
||||
return c.Redacted()
|
||||
}
|
||||
|
||||
func (e EffectiveConfig) RedactedDiagnosticsPayload() any {
|
||||
func (e EffectiveConfig) RedactedSummaryPayload() any {
|
||||
return EffectiveConfig{
|
||||
Config: e.Config.Redacted(),
|
||||
PipelineID: e.PipelineID,
|
||||
Only: append([]string(nil), e.Only...),
|
||||
ReferenceOverrides: append([]pipeline.ReferenceBinding(nil), e.ReferenceOverrides...),
|
||||
ReferenceUnbinds: append([]pipeline.ReferenceUnbind(nil), e.ReferenceUnbinds...),
|
||||
ResolvedPipeline: cloneResolvedPipeline(e.ResolvedPipeline),
|
||||
}
|
||||
}
|
||||
|
||||
func (e EffectiveConfig) RedactedResolvedPipelinePayload() pipeline.ResolvedPipeline {
|
||||
return cloneResolvedPipeline(e.ResolvedPipeline)
|
||||
}
|
||||
|
||||
func cloneResolvedPipeline(in pipeline.ResolvedPipeline) pipeline.ResolvedPipeline {
|
||||
out := in
|
||||
out.Input = cloneModuleBinding(in.Input)
|
||||
out.Chunk = cloneModuleBinding(in.Chunk)
|
||||
out.Output = cloneModuleBinding(in.Output)
|
||||
if len(in.ArtifactLanes) > 0 {
|
||||
out.ArtifactLanes = make([]pipeline.ResolvedArtifactLane, len(in.ArtifactLanes))
|
||||
for i, lane := range in.ArtifactLanes {
|
||||
out.ArtifactLanes[i] = cloneResolvedArtifactLane(lane)
|
||||
out.Input = redactBinding(cloneModuleBinding(in.Input))
|
||||
out.Chunk = redactBinding(cloneModuleBinding(in.Chunk))
|
||||
out.ChunkReferences = pipeline.CloneReferenceTarget(in.ChunkReferences)
|
||||
out.Output = redactBinding(cloneModuleBinding(in.Output))
|
||||
if len(in.ValidatorChains) > 0 {
|
||||
out.ValidatorChains = make([]pipeline.ResolvedValidatorChain, len(in.ValidatorChains))
|
||||
for i, chain := range in.ValidatorChains {
|
||||
out.ValidatorChains[i] = cloneResolvedValidatorChain(chain)
|
||||
}
|
||||
}
|
||||
if len(in.Steps) > 0 {
|
||||
out.Steps = make([]pipeline.ResolvedPipelineStep, len(in.Steps))
|
||||
for i, step := range in.Steps {
|
||||
out.Steps[i] = pipeline.ResolvedPipelineStep{ID: step.ID}
|
||||
if len(step.ArtifactLanes) > 0 {
|
||||
out.Steps[i].ArtifactLanes = make([]pipeline.ResolvedArtifactLane, len(step.ArtifactLanes))
|
||||
for j, lane := range step.ArtifactLanes {
|
||||
out.Steps[i].ArtifactLanes[j] = cloneResolvedArtifactLane(lane)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneResolvedValidatorChain(in pipeline.ResolvedValidatorChain) pipeline.ResolvedValidatorChain {
|
||||
out := in
|
||||
if len(in.Validators) > 0 {
|
||||
out.Validators = make([]pipeline.ResolvedValidator, len(in.Validators))
|
||||
for i, validator := range in.Validators {
|
||||
out.Validators[i] = pipeline.ResolvedValidator{
|
||||
Binding: redactBinding(cloneModuleBinding(validator.Binding)),
|
||||
ExecutionClass: validator.ExecutionClass,
|
||||
Target: validator.Target,
|
||||
ArtifactKind: validator.ArtifactKind,
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -44,14 +75,115 @@ func cloneResolvedPipeline(in pipeline.ResolvedPipeline) pipeline.ResolvedPipeli
|
||||
|
||||
func cloneResolvedArtifactLane(in pipeline.ResolvedArtifactLane) pipeline.ResolvedArtifactLane {
|
||||
out := in
|
||||
out.Extract = cloneModuleBinding(in.Extract)
|
||||
out.Merge = cloneModuleBinding(in.Merge)
|
||||
out.Normalize = cloneModuleBinding(in.Normalize)
|
||||
out.Extract = redactBinding(cloneModuleBinding(in.Extract))
|
||||
out.Merge = redactBinding(cloneModuleBinding(in.Merge))
|
||||
out.Normalize = redactBinding(cloneModuleBinding(in.Normalize))
|
||||
out.ExtractReferences = pipeline.CloneReferenceTarget(in.ExtractReferences)
|
||||
out.MergeReferences = pipeline.CloneReferenceTarget(in.MergeReferences)
|
||||
out.NormalizeReferences = pipeline.CloneReferenceTarget(in.NormalizeReferences)
|
||||
if len(in.Validators) > 0 {
|
||||
out.Validators = make([]pipeline.ModuleBinding, len(in.Validators))
|
||||
for i, binding := range in.Validators {
|
||||
out.Validators[i] = cloneModuleBinding(binding)
|
||||
out.Validators[i] = redactBinding(cloneModuleBinding(binding))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func redactConfig(cfg Config) Config {
|
||||
for id, profile := range cfg.Pipelines {
|
||||
profile.Input = redactBinding(profile.Input)
|
||||
profile.Chunk = redactBinding(profile.Chunk)
|
||||
profile.Output = redactBinding(profile.Output)
|
||||
redactLanes := func(lanes map[string]pipeline.ArtifactLaneProfile) {
|
||||
for laneID, lane := range lanes {
|
||||
lane.Extract = redactBinding(lane.Extract)
|
||||
lane.Merge = redactBinding(lane.Merge)
|
||||
lane.Normalize = redactBinding(lane.Normalize)
|
||||
for i := range lane.Validators {
|
||||
lane.Validators[i] = redactBinding(lane.Validators[i])
|
||||
}
|
||||
lanes[laneID] = lane
|
||||
}
|
||||
}
|
||||
redactLanes(profile.Artifacts)
|
||||
for i := range profile.Steps {
|
||||
redactLanes(profile.Steps[i].Artifacts)
|
||||
}
|
||||
cfg.Pipelines[id] = profile
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func redactBinding(binding pipeline.ModuleBinding) pipeline.ModuleBinding {
|
||||
binding.Options = redactOptions(binding.Options)
|
||||
for i := range binding.Validators.Validators {
|
||||
binding.Validators.Validators[i] = redactBinding(binding.Validators.Validators[i])
|
||||
}
|
||||
return binding
|
||||
}
|
||||
|
||||
func redactOptions(values map[string]any) map[string]any {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
if sensitiveConfigKey(key) {
|
||||
out[key] = "[REDACTED]"
|
||||
continue
|
||||
}
|
||||
out[key] = redactOptionValue(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func redactOptionValue(value any) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
reflected := reflect.ValueOf(value)
|
||||
switch reflected.Kind() {
|
||||
case reflect.Map:
|
||||
if reflected.Type().Key().Kind() != reflect.String {
|
||||
return value
|
||||
}
|
||||
if reflected.IsNil() {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, reflected.Len())
|
||||
iterator := reflected.MapRange()
|
||||
for iterator.Next() {
|
||||
key := iterator.Key().String()
|
||||
if sensitiveConfigKey(key) {
|
||||
out[key] = "[REDACTED]"
|
||||
continue
|
||||
}
|
||||
out[key] = redactOptionValue(iterator.Value().Interface())
|
||||
}
|
||||
return out
|
||||
case reflect.Slice:
|
||||
if reflected.IsNil() {
|
||||
return nil
|
||||
}
|
||||
if reflected.Type().Elem().Kind() == reflect.Uint8 {
|
||||
out := reflect.MakeSlice(reflected.Type(), reflected.Len(), reflected.Len())
|
||||
reflect.Copy(out, reflected)
|
||||
return out.Interface()
|
||||
}
|
||||
fallthrough
|
||||
case reflect.Array:
|
||||
items := make([]any, reflected.Len())
|
||||
for i := 0; i < reflected.Len(); i++ {
|
||||
items[i] = redactOptionValue(reflected.Index(i).Interface())
|
||||
}
|
||||
return items
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func sensitiveConfigKey(key string) bool {
|
||||
key = strings.ToLower(key)
|
||||
return strings.Contains(key, "api_key") || strings.Contains(key, "apikey") || strings.Contains(key, "authorization") || strings.Contains(key, "bearer") || strings.Contains(key, "password") || strings.Contains(key, "secret") || strings.Contains(key, "token")
|
||||
}
|
||||
|
||||
@@ -1,101 +1,266 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestRedactedConfigRemovesAPIKeyValues(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.LLMProfiles[pipeline.DefaultLLMProfile] = LLMProfile{
|
||||
Provider: "openai-compatible",
|
||||
BaseURL: "https://example.invalid/v1",
|
||||
Model: "test-model",
|
||||
APIKey: "secret",
|
||||
APIKeyEnv: "NOTARIUS_TEST_API_KEY",
|
||||
TimeoutSeconds: 600,
|
||||
MaxRetries: 3,
|
||||
MaxConcurrency: 1,
|
||||
}
|
||||
cfg.LLMProfiles["other"] = LLMProfile{APIKey: "other-secret", Model: "other-model"}
|
||||
|
||||
redacted := cfg.Redacted()
|
||||
|
||||
if redacted.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != redactedSecret {
|
||||
t.Fatalf("expected default API key redacted, got %+v", redacted.LLMProfiles[pipeline.DefaultLLMProfile])
|
||||
}
|
||||
if redacted.LLMProfiles["other"].APIKey != redactedSecret {
|
||||
t.Fatalf("expected other API key redacted, got %+v", redacted.LLMProfiles["other"])
|
||||
}
|
||||
if redacted.LLMProfiles[pipeline.DefaultLLMProfile].Model != "test-model" {
|
||||
t.Fatalf("expected non-secret fields preserved, got %+v", redacted.LLMProfiles[pipeline.DefaultLLMProfile])
|
||||
}
|
||||
if cfg.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != "secret" {
|
||||
t.Fatalf("redaction mutated original config")
|
||||
}
|
||||
func TestRedactedResolvedPipelinePayloadRedactsEveryBinding(t *testing.T) {
|
||||
bindings := map[string]pipeline.ModuleBinding{}
|
||||
for _, name := range []string{
|
||||
"input", "chunk", "output", "extract", "merge", "normalize",
|
||||
"resolved-validator", "lane-validator",
|
||||
} {
|
||||
bindings[name] = redactionTestBinding(name)
|
||||
}
|
||||
|
||||
func TestConfigRedactedDiagnosticsPayloadRedactsAPIKeys(t *testing.T) {
|
||||
cfg := Default()
|
||||
profile := cfg.LLMProfiles[pipeline.DefaultLLMProfile]
|
||||
profile.APIKey = "secret"
|
||||
profile.Model = "test-model"
|
||||
cfg.LLMProfiles[pipeline.DefaultLLMProfile] = profile
|
||||
|
||||
payload, ok := cfg.RedactedDiagnosticsPayload().(Config)
|
||||
if !ok {
|
||||
t.Fatalf("expected Config payload, got %T", cfg.RedactedDiagnosticsPayload())
|
||||
}
|
||||
if payload.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != redactedSecret {
|
||||
t.Fatalf("expected API key redacted, got %+v", payload.LLMProfiles[pipeline.DefaultLLMProfile])
|
||||
}
|
||||
if payload.LLMProfiles[pipeline.DefaultLLMProfile].Model != "test-model" {
|
||||
t.Fatalf("expected non-secret fields preserved, got %+v", payload.LLMProfiles[pipeline.DefaultLLMProfile])
|
||||
}
|
||||
if cfg.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != "secret" {
|
||||
t.Fatalf("redacted diagnostics payload mutated original config")
|
||||
resolved := pipeline.ResolvedPipeline{
|
||||
ID: "redaction-test",
|
||||
Digest: "sha256:safe-digest",
|
||||
Input: bindings["input"],
|
||||
Chunk: bindings["chunk"],
|
||||
ChunkReferences: redactionTestReferenceTarget(pipeline.StageChunk, "", "chunk-reference-content"),
|
||||
Steps: []pipeline.ResolvedPipelineStep{{
|
||||
ID: "default",
|
||||
ArtifactLanes: []pipeline.ResolvedArtifactLane{{
|
||||
ID: "safe-lane",
|
||||
ArtifactKind: "safe/artifact",
|
||||
Extract: bindings["extract"],
|
||||
Merge: bindings["merge"],
|
||||
Normalize: bindings["normalize"],
|
||||
Validators: []pipeline.ModuleBinding{bindings["lane-validator"]},
|
||||
ExtractReferences: redactionTestReferenceTarget(pipeline.StageExtract, "safe-lane", "extract-reference-content"),
|
||||
MergeReferences: redactionTestReferenceTarget(pipeline.StageMerge, "safe-lane", "merge-reference-content"),
|
||||
NormalizeReferences: redactionTestReferenceTarget(pipeline.StageNormalize, "safe-lane", "normalize-reference-content"),
|
||||
}},
|
||||
}},
|
||||
ValidatorChains: []pipeline.ResolvedValidatorChain{{
|
||||
Stage: pipeline.StageExtract,
|
||||
LaneID: "safe-lane",
|
||||
ModuleKey: "safe-extract-owner",
|
||||
Validators: []pipeline.ResolvedValidator{{
|
||||
Binding: bindings["resolved-validator"],
|
||||
ExecutionClass: contracts.ExecutionClassDeterministic,
|
||||
Target: pipeline.ValidatorTargetTyped,
|
||||
ArtifactKind: "safe/artifact",
|
||||
}},
|
||||
}},
|
||||
Output: bindings["output"],
|
||||
}
|
||||
effective := EffectiveConfig{
|
||||
Config: Config{Pipelines: map[string]pipeline.PipelineProfile{
|
||||
"redaction-test": {Input: bindings["input"]},
|
||||
}},
|
||||
PipelineID: "redaction-test",
|
||||
ResolvedPipeline: resolved,
|
||||
}
|
||||
|
||||
func TestEffectiveConfigRedactedDiagnosticsPayloadRedactsAndCopies(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
profile := cfg.LLMProfiles[pipeline.DefaultLLMProfile]
|
||||
profile.APIKey = "secret"
|
||||
cfg.LLMProfiles[pipeline.DefaultLLMProfile] = profile
|
||||
lane := cfg.Pipelines["example"].Artifacts["events"]
|
||||
lane.Extract.Options = map[string]any{"temperature": 0.2}
|
||||
cfg.Pipelines["example"].Artifacts["events"] = lane
|
||||
|
||||
effective, err := cfg.Resolve(ResolveInput{
|
||||
PipelineID: "example",
|
||||
Only: []string{"events"},
|
||||
Catalog: fakeCatalog(t),
|
||||
})
|
||||
payload := effective.RedactedResolvedPipelinePayload()
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(encoded)
|
||||
for name := range bindings {
|
||||
for _, forbidden := range []string{name + "-secret", name + "-nested-secret"} {
|
||||
if strings.Contains(text, forbidden) {
|
||||
t.Fatalf("resolved pipeline summary contains %q: %s", forbidden, text)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(text, name+"-safe") {
|
||||
t.Fatalf("resolved pipeline summary does not retain safe option for %q: %s", name, text)
|
||||
}
|
||||
}
|
||||
for _, content := range []string{
|
||||
"chunk-reference-content", "extract-reference-content",
|
||||
"merge-reference-content", "normalize-reference-content",
|
||||
} {
|
||||
if strings.Contains(text, content) {
|
||||
t.Fatalf("resolved pipeline summary contains materialized reference content %q", content)
|
||||
}
|
||||
}
|
||||
for _, safe := range []string{"[REDACTED]", "safe-reference-path", "safe-binding-source"} {
|
||||
if !strings.Contains(text, safe) {
|
||||
t.Fatalf("resolved pipeline summary does not retain %q: %s", safe, text)
|
||||
}
|
||||
}
|
||||
|
||||
payload, ok := effective.RedactedDiagnosticsPayload().(EffectiveConfig)
|
||||
payload.Input.Options["safe"] = "mutated"
|
||||
nested := payload.Input.Options["nested"].([]any)[0].([]any)[0].(map[string]any)
|
||||
nested["neighbor"] = "mutated"
|
||||
payload.ChunkReferences.ReferenceSet.Slots["safe-slot"].Items[0].Content[0] = 'X'
|
||||
payload.ValidatorChains[0].Validators[0].Binding.Options["safe"] = "mutated"
|
||||
|
||||
assertRedactionTestBindingUnchanged(t, effective.ResolvedPipeline.Input, "input")
|
||||
assertRedactionTestBindingUnchanged(t, effective.Config.Pipelines["redaction-test"].Input, "input")
|
||||
assertRedactionTestBindingUnchanged(t, effective.ResolvedPipeline.ValidatorChains[0].Validators[0].Binding, "resolved-validator")
|
||||
if got := string(effective.ResolvedPipeline.ChunkReferences.ReferenceSet.Slots["safe-slot"].Items[0].Content); got != "chunk-reference-content" {
|
||||
t.Fatalf("source reference content mutated through redacted payload: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactedEffectiveConfigPayloadDoesNotAliasSource(t *testing.T) {
|
||||
binding := redactionTestBinding("effective")
|
||||
effective := EffectiveConfig{
|
||||
Config: Config{Pipelines: map[string]pipeline.PipelineProfile{
|
||||
"redaction-test": {Input: binding},
|
||||
}},
|
||||
ResolvedPipeline: pipeline.ResolvedPipeline{Input: binding},
|
||||
}
|
||||
|
||||
payload := effective.RedactedSummaryPayload().(EffectiveConfig)
|
||||
payload.Config.Pipelines["redaction-test"].Input.Options["safe"] = "mutated"
|
||||
payload.ResolvedPipeline.Input.Options["safe"] = "mutated"
|
||||
|
||||
assertRedactionTestBindingUnchanged(t, effective.Config.Pipelines["redaction-test"].Input, "effective")
|
||||
assertRedactionTestBindingUnchanged(t, effective.ResolvedPipeline.Input, "effective")
|
||||
}
|
||||
|
||||
func TestRedactedSummaryPayloadsCoverEveryEffectiveConfigBinding(t *testing.T) {
|
||||
bindings := map[string]pipeline.ModuleBinding{}
|
||||
for _, name := range []string{"input", "chunk", "output", "extract", "merge", "normalize", "lane-validator"} {
|
||||
bindings[name] = redactionTestBinding("summary-" + name)
|
||||
}
|
||||
effective := EffectiveConfig{
|
||||
Config: Config{Pipelines: map[string]pipeline.PipelineProfile{
|
||||
"redaction-test": {
|
||||
Input: bindings["input"],
|
||||
Chunk: bindings["chunk"],
|
||||
Output: bindings["output"],
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"safe-lane": {
|
||||
Extract: bindings["extract"],
|
||||
Merge: bindings["merge"],
|
||||
Normalize: bindings["normalize"],
|
||||
Validators: []pipeline.ModuleBinding{bindings["lane-validator"]},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
ResolvedPipeline: pipeline.ResolvedPipeline{
|
||||
Input: bindings["input"],
|
||||
Chunk: bindings["chunk"],
|
||||
Output: bindings["output"],
|
||||
Steps: []pipeline.ResolvedPipelineStep{{
|
||||
ID: "default",
|
||||
ArtifactLanes: []pipeline.ResolvedArtifactLane{{
|
||||
ID: "safe-lane",
|
||||
Extract: bindings["extract"],
|
||||
Merge: bindings["merge"],
|
||||
Normalize: bindings["normalize"],
|
||||
Validators: []pipeline.ModuleBinding{bindings["lane-validator"]},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
payload, ok := effective.RedactedSummaryPayload().(EffectiveConfig)
|
||||
if !ok {
|
||||
t.Fatalf("expected EffectiveConfig payload, got %T", effective.RedactedDiagnosticsPayload())
|
||||
t.Fatal("RedactedSummaryPayload() returned an unexpected type")
|
||||
}
|
||||
if payload.Config.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != redactedSecret {
|
||||
t.Fatalf("expected nested API key redacted, got %+v", payload.Config.LLMProfiles[pipeline.DefaultLLMProfile])
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.LLMProfiles[pipeline.DefaultLLMProfile].APIKey != "secret" {
|
||||
t.Fatalf("redacted diagnostics payload mutated source config")
|
||||
text := string(encoded)
|
||||
for name := range bindings {
|
||||
if strings.Contains(text, "summary-"+name+"-secret") || strings.Contains(text, "summary-"+name+"-nested-secret") {
|
||||
t.Fatalf("summary payload contains sensitive option for %q: %s", name, text)
|
||||
}
|
||||
if !strings.Contains(text, "summary-"+name+"-safe") {
|
||||
t.Fatalf("summary payload omitted safe option for %q: %s", name, text)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(text, "[REDACTED]") {
|
||||
t.Fatalf("summary payload contains no redaction marker: %s", text)
|
||||
}
|
||||
if payload.PipelineID != effective.PipelineID || payload.ResolvedPipeline.Digest != effective.ResolvedPipeline.Digest {
|
||||
t.Fatalf("expected pipeline metadata preserved, got %+v", payload)
|
||||
}
|
||||
|
||||
payload.Only[0] = "changed"
|
||||
if effective.Only[0] != "events" {
|
||||
t.Fatalf("expected only lanes to be copied")
|
||||
func TestRedactedResolvedPipelinePayloadHandlesTypedOptionContainers(t *testing.T) {
|
||||
type optionMap map[string]string
|
||||
type optionList []optionMap
|
||||
|
||||
typed := optionList{{
|
||||
"api_key": "typed-container-secret",
|
||||
"safe": "typed-container-safe",
|
||||
}}
|
||||
effective := EffectiveConfig{ResolvedPipeline: pipeline.ResolvedPipeline{
|
||||
Input: pipeline.ModuleBinding{Options: map[string]any{"nested": typed}},
|
||||
}}
|
||||
|
||||
payload := effective.RedactedResolvedPipelinePayload()
|
||||
nested, ok := payload.Input.Options["nested"].([]any)
|
||||
if !ok || len(nested) != 1 {
|
||||
t.Fatalf("redacted typed list = %#v", payload.Input.Options["nested"])
|
||||
}
|
||||
payload.ResolvedPipeline.ArtifactLanes[0].Extract.Options["temperature"] = 1.0
|
||||
if effective.ResolvedPipeline.ArtifactLanes[0].Extract.Options["temperature"] != 0.2 {
|
||||
t.Fatalf("expected resolved pipeline options to be copied")
|
||||
item, ok := nested[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("redacted typed map = %#v", nested[0])
|
||||
}
|
||||
if got := item["api_key"]; got != "[REDACTED]" {
|
||||
t.Fatalf("redacted api_key = %v", got)
|
||||
}
|
||||
if got := item["safe"]; got != "typed-container-safe" {
|
||||
t.Fatalf("safe option = %v", got)
|
||||
}
|
||||
|
||||
item["safe"] = "mutated"
|
||||
if got := typed[0]["safe"]; got != "typed-container-safe" {
|
||||
t.Fatalf("source typed map mutated through redacted payload: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func redactionTestBinding(name string) pipeline.ModuleBinding {
|
||||
return pipeline.ModuleBinding{
|
||||
Module: "safe-" + name,
|
||||
Options: map[string]any{
|
||||
"api_key": name + "-secret",
|
||||
"safe": name + "-safe",
|
||||
"nested": []any{[]any{map[string]any{
|
||||
"password": name + "-nested-secret",
|
||||
"neighbor": name + "-nested-safe",
|
||||
}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func redactionTestReferenceTarget(stage pipeline.ModuleStage, laneID, content string) pipeline.ResolvedReferenceTarget {
|
||||
return pipeline.ResolvedReferenceTarget{
|
||||
Stage: stage,
|
||||
LaneID: laneID,
|
||||
Module: "safe-reference-module",
|
||||
Bindings: []pipeline.ReferenceBinding{{
|
||||
Stage: stage,
|
||||
LaneID: laneID,
|
||||
SlotName: "safe-slot",
|
||||
Source: "safe-reference-path",
|
||||
BindingSource: "safe-binding-source",
|
||||
}},
|
||||
ReferenceSet: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"safe-slot": {
|
||||
Slot: contracts.ReferenceSlot{Name: "safe-slot"},
|
||||
Items: []contracts.ReferenceItem{{
|
||||
SlotName: "safe-slot",
|
||||
Content: []byte(content),
|
||||
Digest: "sha256:safe-reference-digest",
|
||||
BindingSource: "safe-binding-source",
|
||||
}},
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func assertRedactionTestBindingUnchanged(t *testing.T, binding pipeline.ModuleBinding, name string) {
|
||||
t.Helper()
|
||||
if got := binding.Options["safe"]; got != name+"-safe" {
|
||||
t.Fatalf("source safe option = %v, want %q", got, name+"-safe")
|
||||
}
|
||||
nested := binding.Options["nested"].([]any)[0].([]any)[0].(map[string]any)
|
||||
if got := nested["neighbor"]; got != name+"-nested-safe" {
|
||||
t.Fatalf("source nested safe option = %v, want %q", got, name+"-nested-safe")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,79 +2,84 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
const providerOpenAICompatible = "openai-compatible"
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if err := validateLLMProfiles(c.LLMProfiles); err != nil {
|
||||
c.Concurrency.recomputeStageWorkerDefaults()
|
||||
if err := validateScriptorium(c.Scriptorium); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDiagnostics(c.Diagnostics); err != nil {
|
||||
if err := validateStateSurfaces(c); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Concurrency.TotalLLM <= 0 {
|
||||
return fmt.Errorf("total LLM concurrency must be greater than zero")
|
||||
}
|
||||
return validatePipelineProfiles(c.Pipelines, c.LLMProfiles)
|
||||
if err := validateStageWorkers(c.Concurrency); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePipelineProfiles(c.Pipelines)
|
||||
}
|
||||
|
||||
func (c Config) LLMProfile(id string) (LLMProfile, bool) {
|
||||
trimmedID := strings.TrimSpace(id)
|
||||
for rawID, profile := range c.LLMProfiles {
|
||||
if strings.TrimSpace(rawID) == trimmedID {
|
||||
return profile, true
|
||||
func validateStageWorkers(cfg ConcurrencyConfig) error {
|
||||
keys := make([]string, 0, len(cfg.StageWorkers))
|
||||
for key := range cfg.StageWorkers {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return fmt.Errorf("concurrency.stage_workers key must not be empty")
|
||||
}
|
||||
if key != "extract" {
|
||||
return fmt.Errorf("concurrency.stage_workers key %q is not supported", key)
|
||||
}
|
||||
}
|
||||
return LLMProfile{}, false
|
||||
extractWorkers, ok := cfg.StageWorkers["extract"]
|
||||
if !ok {
|
||||
extractWorkers = cfg.TotalLLM
|
||||
}
|
||||
if extractWorkers < 1 || extractWorkers > cfg.TotalLLM {
|
||||
return fmt.Errorf("concurrency.stage_workers.extract must be between 1 and concurrency.total_llm (%d)", cfg.TotalLLM)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLLMProfiles(profiles map[string]LLMProfile) error {
|
||||
seen := make(map[string]struct{}, len(profiles))
|
||||
for rawID, profile := range profiles {
|
||||
id := strings.TrimSpace(rawID)
|
||||
if id == "" {
|
||||
return fmt.Errorf("LLM profile id must not be empty")
|
||||
func validateScriptorium(cfg ScriptoriumConfig) error {
|
||||
if strings.TrimSpace(cfg.ProfileDir) != "" && strings.TrimSpace(cfg.ProfileFile) != "" {
|
||||
return fmt.Errorf("scriptorium profile_dir and profile_file are mutually exclusive")
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return fmt.Errorf("LLM profile id %q is duplicated after trimming", id)
|
||||
return nil
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
|
||||
provider := strings.TrimSpace(profile.Provider)
|
||||
if provider != "" && provider != providerOpenAICompatible {
|
||||
return fmt.Errorf("LLM profile %q provider %q is not supported", id, provider)
|
||||
func validateStateSurfaces(cfg Config) error {
|
||||
if strings.TrimSpace(cfg.Output.Directory) == "" {
|
||||
return fmt.Errorf("output.directory must not be empty")
|
||||
}
|
||||
if profile.TimeoutSeconds < 0 {
|
||||
return fmt.Errorf("LLM profile %q timeout seconds must not be negative", id)
|
||||
if strings.TrimSpace(cfg.Debug.Directory) == "" {
|
||||
return fmt.Errorf("debug.directory must not be empty")
|
||||
}
|
||||
if profile.MaxRetries < 0 {
|
||||
return fmt.Errorf("LLM profile %q max retries must not be negative", id)
|
||||
if err := cfg.Cache.ChunkPlans.Mode.Validate(); err != nil {
|
||||
return fmt.Errorf("cache.chunk_plans.mode: %w", err)
|
||||
}
|
||||
if profile.MaxConcurrency < 0 {
|
||||
return fmt.Errorf("LLM profile %q max concurrency must not be negative", id)
|
||||
for name, value := range map[string]string{
|
||||
"output.directory": cfg.Output.Directory,
|
||||
"cache.chunk_plans.directory": cfg.Cache.ChunkPlans.Directory,
|
||||
"cache.checkpoints.directory": cfg.Cache.Checkpoints.Directory,
|
||||
"debug.directory": cfg.Debug.Directory,
|
||||
} {
|
||||
if strings.ContainsRune(value, '\x00') {
|
||||
return fmt.Errorf("%s must not contain NUL", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDiagnostics(cfg DiagnosticsConfig) error {
|
||||
if strings.TrimSpace(cfg.WorkDir) == "" {
|
||||
return fmt.Errorf("diagnostics work dir must not be empty")
|
||||
}
|
||||
switch cfg.Retention {
|
||||
case "", diagnostics.RetentionAuto, diagnostics.RetentionAlways, diagnostics.RetentionNever:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("diagnostics retention %q is not supported", cfg.Retention)
|
||||
}
|
||||
}
|
||||
|
||||
func validatePipelineProfiles(profiles map[string]pipeline.PipelineProfile, llmProfiles map[string]LLMProfile) error {
|
||||
func validatePipelineProfiles(profiles map[string]pipeline.PipelineProfile) error {
|
||||
seen := make(map[string]struct{}, len(profiles))
|
||||
for rawID, profile := range profiles {
|
||||
id := strings.TrimSpace(rawID)
|
||||
@@ -89,65 +94,197 @@ func validatePipelineProfiles(profiles map[string]pipeline.PipelineProfile, llmP
|
||||
if profile.ID != "" && strings.TrimSpace(profile.ID) != id {
|
||||
return fmt.Errorf("pipeline %q profile id %q does not match map key", id, profile.ID)
|
||||
}
|
||||
if err := validateBindingLLMProfile(id, "", "input", profile.Input, llmProfiles); err != nil {
|
||||
if err := validateBinding(id, "", "input", profile.Input, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateBindingLLMProfile(id, "", "chunk", profile.Chunk, llmProfiles); err != nil {
|
||||
if err := validateBinding(id, "", "chunk", profile.Chunk, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateBindingLLMProfile(id, "", "output", profile.Output, llmProfiles); err != nil {
|
||||
if err := validateBinding(id, "", "output", profile.Output, false); err != nil {
|
||||
return err
|
||||
}
|
||||
for rawLaneID, lane := range profile.Artifacts {
|
||||
if err := validateReferenceMap(id, "", profile.References); err != nil {
|
||||
return err
|
||||
}
|
||||
explicitSteps := profile.Steps != nil
|
||||
if len(profile.Artifacts) > 0 && explicitSteps {
|
||||
return fmt.Errorf("pipeline %q must not declare both artifacts and steps", id)
|
||||
}
|
||||
steps := profile.Steps
|
||||
if !explicitSteps {
|
||||
steps = []pipeline.PipelineStepProfile{{ID: "default", Artifacts: profile.Artifacts}}
|
||||
}
|
||||
if explicitSteps && len(steps) == 0 {
|
||||
return fmt.Errorf("pipeline %q must declare at least one ordered step", id)
|
||||
}
|
||||
seenSteps := make(map[string]struct{}, len(steps))
|
||||
seenLanes := make(map[string]struct{})
|
||||
for index, step := range steps {
|
||||
stepID := strings.TrimSpace(step.ID)
|
||||
if stepID == "" {
|
||||
return fmt.Errorf("pipeline %q step[%d] id must not be empty", id, index)
|
||||
}
|
||||
if _, ok := seenSteps[stepID]; ok {
|
||||
return fmt.Errorf("pipeline %q step id %q is duplicated after trimming", id, stepID)
|
||||
}
|
||||
seenSteps[stepID] = struct{}{}
|
||||
if explicitSteps {
|
||||
if err := validateReferenceMapForContext(id, "", "step "+stepID, step.References, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for rawLaneID, lane := range step.Artifacts {
|
||||
laneID := strings.TrimSpace(rawLaneID)
|
||||
if laneID == "" {
|
||||
return fmt.Errorf("pipeline %q artifact lane id must not be empty", id)
|
||||
}
|
||||
if err := validateBindingLLMProfile(id, laneID, "extract", lane.Extract, llmProfiles); err != nil {
|
||||
if _, ok := seenLanes[laneID]; ok {
|
||||
if !explicitSteps {
|
||||
return fmt.Errorf("pipeline %q artifact lane id %q is duplicated after trimming", id, laneID)
|
||||
}
|
||||
return fmt.Errorf("pipeline %q artifact lane id %q is duplicated across steps", id, laneID)
|
||||
}
|
||||
seenLanes[laneID] = struct{}{}
|
||||
if err := validateReferenceMapForContext(id, laneID, "", lane.References, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateBindingLLMProfile(id, laneID, "merge", lane.Merge, llmProfiles); err != nil {
|
||||
if err := validateBinding(id, laneID, "extract", lane.Extract, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateBindingLLMProfile(id, laneID, "normalize", lane.Normalize, llmProfiles); err != nil {
|
||||
if err := validateBinding(id, laneID, "merge", lane.Merge, true); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, validator := range lane.Validators {
|
||||
if err := validateBindingLLMProfile(id, laneID, fmt.Sprintf("validator[%d]", i), validator, llmProfiles); err != nil {
|
||||
if err := validateBinding(id, laneID, "normalize", lane.Normalize, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(lane.Validators) > 0 {
|
||||
return fmt.Errorf("pipeline %q lane %q validators are not supported at artifact lane level; use extract.validators, merge.validators, or normalize.validators", id, laneID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBinding(
|
||||
pipelineID string,
|
||||
laneID string,
|
||||
slot string,
|
||||
binding pipeline.ModuleBinding,
|
||||
referencesAllowed bool,
|
||||
) error {
|
||||
if err := validateBindingLLMProfile(pipelineID, laneID, slot, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
if binding.Retries < 0 {
|
||||
if laneID != "" {
|
||||
return fmt.Errorf("pipeline %q lane %q %s retries must be greater than or equal to zero", pipelineID, laneID, slot)
|
||||
}
|
||||
return fmt.Errorf("pipeline %q %s retries must be greater than or equal to zero", pipelineID, slot)
|
||||
}
|
||||
if err := validateValidatorOverride(pipelineID, laneID, slot, binding.Validators); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(binding.References) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !referencesAllowed {
|
||||
if laneID != "" {
|
||||
return fmt.Errorf("pipeline %q lane %q %s references are not supported", pipelineID, laneID, slot)
|
||||
}
|
||||
return fmt.Errorf("pipeline %q %s references are not supported", pipelineID, slot)
|
||||
}
|
||||
return validateReferenceMapForContext(pipelineID, laneID, slot, binding.References, true)
|
||||
}
|
||||
|
||||
func validateValidatorOverride(pipelineID string, laneID string, slot string, override pipeline.ValidatorOverride) error {
|
||||
if !override.Set {
|
||||
return nil
|
||||
}
|
||||
switch slot {
|
||||
case "chunk", "extract", "merge", "normalize":
|
||||
default:
|
||||
return fmt.Errorf("%s validators are not supported", referenceContext(pipelineID, laneID, slot))
|
||||
}
|
||||
for i, validator := range override.Validators {
|
||||
context := fmt.Sprintf("%s validators[%d]", referenceContext(pipelineID, laneID, slot), i)
|
||||
if strings.TrimSpace(validator.Module) == "" {
|
||||
return fmt.Errorf("%s module must not be empty", context)
|
||||
}
|
||||
if len(validator.References) > 0 {
|
||||
return fmt.Errorf("%s references are not supported", context)
|
||||
}
|
||||
if validator.Validators.Set {
|
||||
return fmt.Errorf("%s nested validators are not supported", context)
|
||||
}
|
||||
if validator.Retries != 0 {
|
||||
return fmt.Errorf("%s retries are not supported", context)
|
||||
}
|
||||
if validator.LLMProfile != "" && strings.TrimSpace(validator.LLMProfile) == "" {
|
||||
return fmt.Errorf("%s llm_profile must not be empty when set", context)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateReferenceMap(pipelineID string, laneID string, references map[string]pipeline.ReferenceSource) error {
|
||||
return validateReferenceMapForContext(pipelineID, laneID, "", references, false)
|
||||
}
|
||||
|
||||
func validateReferenceMapForContext(pipelineID string, laneID string, slot string, references map[string]pipeline.ReferenceSource, generatedAllowed bool) error {
|
||||
seen := make(map[string]struct{}, len(references))
|
||||
for rawSlotName, source := range references {
|
||||
slotName := strings.TrimSpace(rawSlotName)
|
||||
if slotName == "" {
|
||||
return fmt.Errorf("%s reference slot name must not be empty", referenceContext(pipelineID, laneID, slot))
|
||||
}
|
||||
if _, ok := seen[slotName]; ok {
|
||||
return fmt.Errorf("%s reference slot %q is duplicated after trimming", referenceContext(pipelineID, laneID, slot), slotName)
|
||||
}
|
||||
seen[slotName] = struct{}{}
|
||||
if source.Artifact != nil {
|
||||
if !generatedAllowed {
|
||||
return fmt.Errorf("%s reference slot %q must use an external path", referenceContext(pipelineID, laneID, slot), slotName)
|
||||
}
|
||||
if strings.TrimSpace(source.Artifact.Step) == "" || strings.TrimSpace(source.Artifact.Lane) == "" {
|
||||
return fmt.Errorf("%s reference slot %q artifact selector step and lane must not be empty", referenceContext(pipelineID, laneID, slot), slotName)
|
||||
}
|
||||
if strings.TrimSpace(source.Path) != "" {
|
||||
return fmt.Errorf("%s reference slot %q must contain exactly one source form", referenceContext(pipelineID, laneID, slot), slotName)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(source.Path) == "" {
|
||||
return fmt.Errorf("%s reference slot %q source must not be empty", referenceContext(pipelineID, laneID, slot), slotName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func referenceContext(pipelineID string, laneID string, slot string) string {
|
||||
if laneID != "" && slot != "" {
|
||||
return fmt.Sprintf("pipeline %q lane %q %s", pipelineID, laneID, slot)
|
||||
}
|
||||
if laneID != "" {
|
||||
return fmt.Sprintf("pipeline %q lane %q", pipelineID, laneID)
|
||||
}
|
||||
if slot != "" {
|
||||
return fmt.Sprintf("pipeline %q %s", pipelineID, slot)
|
||||
}
|
||||
return fmt.Sprintf("pipeline %q", pipelineID)
|
||||
}
|
||||
|
||||
func validateBindingLLMProfile(
|
||||
pipelineID string,
|
||||
laneID string,
|
||||
slot string,
|
||||
binding pipeline.ModuleBinding,
|
||||
profiles map[string]LLMProfile,
|
||||
) error {
|
||||
profileID := strings.TrimSpace(binding.LLMProfile)
|
||||
if profileID == "" {
|
||||
profileID = pipeline.DefaultLLMProfile
|
||||
if binding.LLMProfile != "" && strings.TrimSpace(binding.LLMProfile) == "" {
|
||||
if laneID != "" {
|
||||
return fmt.Errorf("pipeline %q lane %q %s llm_profile must not be empty when set", pipelineID, laneID, slot)
|
||||
}
|
||||
return fmt.Errorf("pipeline %q %s llm_profile must not be empty when set", pipelineID, slot)
|
||||
}
|
||||
if hasLLMProfile(profiles, profileID) {
|
||||
return nil
|
||||
}
|
||||
if laneID != "" {
|
||||
return fmt.Errorf("pipeline %q lane %q %s references unknown LLM profile %q", pipelineID, laneID, slot, profileID)
|
||||
}
|
||||
return fmt.Errorf("pipeline %q %s references unknown LLM profile %q", pipelineID, slot, profileID)
|
||||
}
|
||||
|
||||
func hasLLMProfile(profiles map[string]LLMProfile, profileID string) bool {
|
||||
profileID = strings.TrimSpace(profileID)
|
||||
for rawID := range profiles {
|
||||
if strings.TrimSpace(rawID) == profileID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
446
internal/core/config/validation_contract_test.go
Normal file
446
internal/core/config/validation_contract_test.go
Normal file
@@ -0,0 +1,446 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestValidateConcurrencyRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*Config)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "non-positive total",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.TotalLLM = 0
|
||||
},
|
||||
want: "total LLM concurrency must be greater than zero",
|
||||
},
|
||||
{
|
||||
name: "worker below one",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.TotalLLM = 3
|
||||
cfg.Concurrency.StageWorkers = map[string]int{"extract": 0}
|
||||
cfg.Concurrency.extractWorkersConfigured = true
|
||||
},
|
||||
want: "stage_workers.extract must be between 1",
|
||||
},
|
||||
{
|
||||
name: "worker above total",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.TotalLLM = 3
|
||||
cfg.Concurrency.StageWorkers = map[string]int{"extract": 4}
|
||||
cfg.Concurrency.extractWorkersConfigured = true
|
||||
},
|
||||
want: "stage_workers.extract must be between 1",
|
||||
},
|
||||
{
|
||||
name: "worker lower boundary",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.TotalLLM = 3
|
||||
cfg.Concurrency.StageWorkers = map[string]int{"extract": 1}
|
||||
cfg.Concurrency.extractWorkersConfigured = true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "worker upper boundary",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.TotalLLM = 3
|
||||
cfg.Concurrency.StageWorkers = map[string]int{"extract": 3}
|
||||
cfg.Concurrency.extractWorkersConfigured = true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown worker key",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.StageWorkers = map[string]int{"worker": 1}
|
||||
},
|
||||
want: "stage_workers key \"worker\" is not supported",
|
||||
},
|
||||
{
|
||||
name: "blank worker key",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.StageWorkers = map[string]int{" ": 1}
|
||||
},
|
||||
want: "stage_workers key must not be empty",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tt.setup(&cfg)
|
||||
err := cfg.Validate()
|
||||
if tt.want == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("Validate() error = %v, want context %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateScriptoriumSourcesAreMutuallyExclusive(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Scriptorium = ScriptoriumConfig{ProfileDir: "./profiles", ProfileFile: "./profile.yml"}
|
||||
assertValidationContains(t, cfg, "scriptorium profile_dir and profile_file are mutually exclusive")
|
||||
}
|
||||
|
||||
func TestValidateStateSurfaceRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*Config)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "blank output root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Output.Directory = " "
|
||||
},
|
||||
want: "output.directory must not be empty",
|
||||
},
|
||||
{
|
||||
name: "blank debug root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Debug.Directory = " "
|
||||
},
|
||||
want: "debug.directory must not be empty",
|
||||
},
|
||||
{
|
||||
name: "NUL in output root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Output.Directory = "./out\x00put"
|
||||
},
|
||||
want: "output.directory must not contain NUL",
|
||||
},
|
||||
{
|
||||
name: "NUL in chunk plan root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Cache.ChunkPlans.Directory = "./plans\x00"
|
||||
},
|
||||
want: "cache.chunk_plans.directory must not contain NUL",
|
||||
},
|
||||
{
|
||||
name: "NUL in checkpoint root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Cache.Checkpoints.Directory = "./checkpoints\x00"
|
||||
},
|
||||
want: "cache.checkpoints.directory must not contain NUL",
|
||||
},
|
||||
{
|
||||
name: "NUL in debug root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Debug.Directory = "./debug\x00"
|
||||
},
|
||||
want: "debug.directory must not contain NUL",
|
||||
},
|
||||
{
|
||||
name: "invalid chunk plan mode",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Cache.ChunkPlans.Mode = pipeline.ChunkCacheMode("invalid")
|
||||
},
|
||||
want: "cache.chunk_plans.mode:",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tt.setup(&cfg)
|
||||
assertValidationContains(t, cfg, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIdentifiersAfterTrimming(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*Config)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty pipeline id",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{" ": {}}
|
||||
},
|
||||
want: "pipeline id must not be empty",
|
||||
},
|
||||
{
|
||||
name: "duplicate pipeline ids",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": {}, " main ": {}}
|
||||
},
|
||||
want: "pipeline id \"main\" is duplicated after trimming",
|
||||
},
|
||||
{
|
||||
name: "empty lane id",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.Artifacts = map[string]pipeline.ArtifactLaneProfile{" ": {}}
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "artifact lane id must not be empty",
|
||||
},
|
||||
{
|
||||
name: "duplicate lane ids",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.Artifacts = map[string]pipeline.ArtifactLaneProfile{"spells": {}, " spells ": {}}
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "artifact lane id \"spells\" is duplicated after trimming",
|
||||
},
|
||||
{
|
||||
name: "empty reference slot",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.References = pipeline.ExternalReferenceMap(map[string]string{" ": "source.txt"})
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "reference slot name must not be empty",
|
||||
},
|
||||
{
|
||||
name: "duplicate reference slots",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.References = pipeline.ExternalReferenceMap(map[string]string{"slot": "one.txt", " slot ": "two.txt"})
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "reference slot \"slot\" is duplicated after trimming",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tt.setup(&cfg)
|
||||
assertValidationContains(t, cfg, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBindingRetriesAndProfiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*Config)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "negative retries",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.Input.Retries = -1
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "input retries must be greater than or equal to zero",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only input profile",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.Input.LLMProfile = " "
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "input llm_profile must not be empty when set",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tt.setup(&cfg)
|
||||
assertValidationContains(t, cfg, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReferencesAreUnsupportedOnInputAndOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
set func(*pipeline.PipelineProfile)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "input references",
|
||||
set: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Input.References = pipeline.ExternalReferenceMap(map[string]string{"slot": "source.txt"})
|
||||
},
|
||||
want: "input references are not supported",
|
||||
},
|
||||
{
|
||||
name: "output references",
|
||||
set: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Output.References = pipeline.ExternalReferenceMap(map[string]string{"slot": "source.txt"})
|
||||
},
|
||||
want: "output references are not supported",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := validationProfile()
|
||||
tt.set(&profile)
|
||||
cfg := Default()
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
assertValidationContains(t, cfg, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateValidatorBindingRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*pipeline.PipelineProfile)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty validator module",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{}},
|
||||
}
|
||||
},
|
||||
want: "chunk validators[0] module must not be empty",
|
||||
},
|
||||
{
|
||||
name: "validator retries",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{
|
||||
Module: "validator",
|
||||
Retries: 1,
|
||||
}},
|
||||
}
|
||||
},
|
||||
want: "chunk validators[0] retries are not supported",
|
||||
},
|
||||
{
|
||||
name: "validator references",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{
|
||||
Module: "validator",
|
||||
References: pipeline.ExternalReferenceMap(map[string]string{"slot": "source.txt"}),
|
||||
}},
|
||||
}
|
||||
},
|
||||
want: "chunk validators[0] references are not supported",
|
||||
},
|
||||
{
|
||||
name: "nested validators",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{
|
||||
Module: "validator",
|
||||
Validators: pipeline.ValidatorOverride{Set: true},
|
||||
}},
|
||||
}
|
||||
},
|
||||
want: "chunk validators[0] nested validators are not supported",
|
||||
},
|
||||
{
|
||||
name: "input validator chain",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Input.Validators = pipeline.ValidatorOverride{Set: true}
|
||||
},
|
||||
want: "input validators are not supported",
|
||||
},
|
||||
{
|
||||
name: "output validator chain",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Output.Validators = pipeline.ValidatorOverride{Set: true}
|
||||
},
|
||||
want: "output validators are not supported",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := validationProfile()
|
||||
tt.setup(&profile)
|
||||
cfg := Default()
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
assertValidationContains(t, cfg, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLaneValidatorCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lane func(*pipeline.ArtifactLaneProfile)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "deprecated non-empty lane validators",
|
||||
lane: func(lane *pipeline.ArtifactLaneProfile) {
|
||||
lane.Validators = []pipeline.ModuleBinding{{Module: "old-validator"}}
|
||||
},
|
||||
want: "validators are not supported at artifact lane level",
|
||||
},
|
||||
{
|
||||
name: "stage validators omitted",
|
||||
lane: func(lane *pipeline.ArtifactLaneProfile) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stage validators explicitly empty",
|
||||
lane: func(lane *pipeline.ArtifactLaneProfile) {
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{Set: true}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stage validators configured",
|
||||
lane: func(lane *pipeline.ArtifactLaneProfile) {
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{Module: "validator"}},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := validationProfile()
|
||||
lane := profile.Artifacts["lane"]
|
||||
tt.lane(&lane)
|
||||
profile.Artifacts["lane"] = lane
|
||||
cfg := Default()
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
err := cfg.Validate()
|
||||
if tt.want == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("Validate() error = %v, want context %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validationProfile() pipeline.PipelineProfile {
|
||||
return pipeline.PipelineProfile{
|
||||
ID: "main",
|
||||
Input: pipeline.Binding("input"),
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"lane": {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func assertValidationContains(t *testing.T, cfg Config, want string) {
|
||||
t.Helper()
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("Validate() error = %v, want context %q", err, want)
|
||||
}
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestValidateSuccessForValidConfig(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnknownLLMProfileReferencedByBinding(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
lane := cfg.Pipelines["example"].Artifacts["events"]
|
||||
lane.Extract.LLMProfile = "missing"
|
||||
cfg.Pipelines["example"].Artifacts["events"] = lane
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown LLM profile") || !strings.Contains(err.Error(), "events") {
|
||||
t.Fatalf("expected unknown LLM profile error with lane context, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidProvider(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
profile := cfg.LLMProfiles["default"]
|
||||
profile.Provider = "unsupported"
|
||||
cfg.LLMProfiles["default"] = profile
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "provider") {
|
||||
t.Fatalf("expected provider error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidNumericFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(Config) Config
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "total concurrency",
|
||||
mutate: func(cfg Config) Config {
|
||||
cfg.Concurrency.TotalLLM = 0
|
||||
return cfg
|
||||
},
|
||||
want: "total LLM concurrency",
|
||||
},
|
||||
{
|
||||
name: "timeout",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.LLMProfiles["default"]
|
||||
profile.TimeoutSeconds = -1
|
||||
cfg.LLMProfiles["default"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: "timeout",
|
||||
},
|
||||
{
|
||||
name: "max retries",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.LLMProfiles["default"]
|
||||
profile.MaxRetries = -1
|
||||
cfg.LLMProfiles["default"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: "max retries",
|
||||
},
|
||||
{
|
||||
name: "max concurrency",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.LLMProfiles["default"]
|
||||
profile.MaxConcurrency = -1
|
||||
cfg.LLMProfiles["default"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: "max concurrency",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.mutate(validConfig()).Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAllowsPartialLLMProfileNumericConfig(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.LLMProfiles["retry-only"] = LLMProfile{MaxRetries: 3}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidDiagnosticsRetention(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.Diagnostics.Retention = diagnostics.RetentionMode("sometimes")
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "retention") {
|
||||
t.Fatalf("expected retention error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsEmptyIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(Config) Config
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "LLM profile",
|
||||
mutate: func(cfg Config) Config {
|
||||
cfg.LLMProfiles[" "] = LLMProfile{}
|
||||
return cfg
|
||||
},
|
||||
want: "LLM profile id",
|
||||
},
|
||||
{
|
||||
name: "pipeline",
|
||||
mutate: func(cfg Config) Config {
|
||||
cfg.Pipelines[" "] = pipeline.PipelineProfile{}
|
||||
return cfg
|
||||
},
|
||||
want: "pipeline id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.mutate(validConfig()).Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsIDsDuplicatedAfterTrimming(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(Config) Config
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "LLM profile",
|
||||
mutate: func(cfg Config) Config {
|
||||
cfg.LLMProfiles[" default "] = cfg.LLMProfiles["default"]
|
||||
return cfg
|
||||
},
|
||||
want: "duplicated",
|
||||
},
|
||||
{
|
||||
name: "pipeline",
|
||||
mutate: func(cfg Config) Config {
|
||||
cfg.Pipelines[" example "] = cfg.Pipelines["example"]
|
||||
return cfg
|
||||
},
|
||||
want: "duplicated",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.mutate(validConfig()).Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUsesTrimmedLLMProfileIDs(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.LLMProfiles[" default "] = cfg.LLMProfiles["default"]
|
||||
delete(cfg.LLMProfiles, "default")
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
if _, ok := cfg.LLMProfile("default"); !ok {
|
||||
t.Fatalf("expected trimmed LLM profile lookup to succeed")
|
||||
}
|
||||
}
|
||||
|
||||
func validConfig() Config {
|
||||
cfg := Default()
|
||||
profile := cfg.LLMProfiles["default"]
|
||||
profile.BaseURL = "https://example.invalid/v1"
|
||||
profile.Model = "test-model"
|
||||
cfg.LLMProfiles["default"] = profile
|
||||
cfg.Pipelines["example"] = pipeline.PipelineProfile{
|
||||
Input: pipeline.Binding("fake/input"),
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"events": {
|
||||
Extract: pipeline.Binding("fake/extract"),
|
||||
Validators: []pipeline.ModuleBinding{pipeline.Binding("fake/validator")},
|
||||
},
|
||||
"notes": {
|
||||
Extract: pipeline.Binding("fake/extract"),
|
||||
},
|
||||
},
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func fakeCatalog(t *testing.T, overrides ...pipeline.ModuleSpec) pipeline.ModuleCatalog {
|
||||
t.Helper()
|
||||
specs := map[string]pipeline.ModuleSpec{
|
||||
"fake/input": {
|
||||
Key: "fake/input",
|
||||
Stage: pipeline.StageInput,
|
||||
Provides: []string{"source"},
|
||||
},
|
||||
"generic": {
|
||||
Key: "generic",
|
||||
Stage: pipeline.StageChunk,
|
||||
Requires: []string{"source"},
|
||||
Provides: []string{"chunks"},
|
||||
},
|
||||
"fake/extract": {
|
||||
Key: "fake/extract",
|
||||
Stage: pipeline.StageExtract,
|
||||
Requires: []string{"chunks"},
|
||||
Provides: []string{"artifact"},
|
||||
},
|
||||
"appendorder": {
|
||||
Key: "appendorder",
|
||||
Stage: pipeline.StageMerge,
|
||||
Requires: []string{"artifact"},
|
||||
Provides: []string{"merged"},
|
||||
},
|
||||
"noop": {
|
||||
Key: "noop",
|
||||
Stage: pipeline.StageNormalize,
|
||||
Requires: []string{"merged"},
|
||||
Provides: []string{"normalized"},
|
||||
},
|
||||
"fake/validator": {
|
||||
Key: "fake/validator",
|
||||
Stage: pipeline.StageValidate,
|
||||
Requires: []string{"normalized"},
|
||||
Provides: []string{"validated"},
|
||||
},
|
||||
"json": {
|
||||
Key: "json",
|
||||
Stage: pipeline.StageOutput,
|
||||
Requires: []string{"normalized"},
|
||||
},
|
||||
}
|
||||
for _, override := range overrides {
|
||||
specs[override.Key] = override
|
||||
}
|
||||
|
||||
inputs := pipeline.NewInputAdapterRegistry()
|
||||
chunkers := pipeline.NewChunkerRegistry()
|
||||
extractors := pipeline.NewExtractorRegistry()
|
||||
mergers := pipeline.NewMergerRegistry()
|
||||
normalizers := pipeline.NewNormalizerRegistry()
|
||||
validators := pipeline.NewValidatorRegistry()
|
||||
outputs := pipeline.NewOutputEncoderRegistry()
|
||||
|
||||
mustRegisterInput(t, inputs, specs["fake/input"])
|
||||
mustRegisterChunker(t, chunkers, specs["generic"])
|
||||
mustRegisterExtractor(t, extractors, specs["fake/extract"])
|
||||
mustRegisterMerger(t, mergers, specs["appendorder"])
|
||||
mustRegisterNormalizer(t, normalizers, specs["noop"])
|
||||
mustRegisterValidator(t, validators, specs["fake/validator"])
|
||||
mustRegisterOutput(t, outputs, specs["json"])
|
||||
|
||||
return pipeline.ModuleCatalog{
|
||||
Inputs: inputs,
|
||||
Chunkers: chunkers,
|
||||
Extractors: extractors,
|
||||
Mergers: mergers,
|
||||
Normalizers: normalizers,
|
||||
Validators: validators,
|
||||
Outputs: outputs,
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterInput(t *testing.T, registry *pipeline.InputAdapterRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.InputAdapter, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register input: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterChunker(t *testing.T, registry *pipeline.ChunkerRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.Chunker, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register chunker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterExtractor(t *testing.T, registry *pipeline.ExtractorRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.Extractor, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register extractor: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.Merger, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register merger: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterNormalizer(t *testing.T, registry *pipeline.NormalizerRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.Normalizer, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register normalizer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterValidator(t *testing.T, registry *pipeline.ValidatorRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.Validator, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register validator: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterOutput(t *testing.T, registry *pipeline.OutputEncoderRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.OutputEncoder, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register output: %v", err)
|
||||
}
|
||||
}
|
||||
91
internal/core/debugbundle/bundle.go
Normal file
91
internal/core/debugbundle/bundle.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Package debugbundle owns explicitly requested per-run debug bundles.
|
||||
package debugbundle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Bundle struct {
|
||||
path, summaryRoot, traceRoot string
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
func Allocate(parent, runID string, startedAt time.Time) (*Bundle, error) {
|
||||
parent = strings.TrimSpace(parent)
|
||||
if parent == "" {
|
||||
return nil, fmt.Errorf("debug parent must not be empty")
|
||||
}
|
||||
if err := validateRunID(runID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(parent, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create debug parent %q: %w", parent, err)
|
||||
}
|
||||
path := filepath.Join(parent, runID)
|
||||
if err := os.Mkdir(path, 0o700); err != nil {
|
||||
if os.IsExist(err) {
|
||||
return nil, fmt.Errorf("debug bundle %q already exists", path)
|
||||
}
|
||||
return nil, fmt.Errorf("create debug bundle %q: %w", path, err)
|
||||
}
|
||||
summary, trace := filepath.Join(path, "summary"), filepath.Join(path, "trace")
|
||||
if err := os.Mkdir(summary, 0o700); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return nil, fmt.Errorf("create debug summary %q: %w", summary, err)
|
||||
}
|
||||
if err := os.Mkdir(trace, 0o700); err != nil {
|
||||
_ = os.RemoveAll(path)
|
||||
return nil, fmt.Errorf("create debug trace %q: %w", trace, err)
|
||||
}
|
||||
return &Bundle{path: path, summaryRoot: summary, traceRoot: trace, createdAt: startedAt}, nil
|
||||
}
|
||||
|
||||
func validateRunID(runID string) error {
|
||||
if runID == "" {
|
||||
return fmt.Errorf("debug run ID must not be empty")
|
||||
}
|
||||
if runID != strings.TrimSpace(runID) || strings.ContainsAny(runID, `/\\`) || filepath.IsAbs(runID) || filepath.Clean(runID) != runID || runID == "." || runID == ".." {
|
||||
return fmt.Errorf("debug run ID %q must be one safe path component", runID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (b *Bundle) Path() string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
return b.path
|
||||
}
|
||||
func (b *Bundle) SummaryRoot() string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
return b.summaryRoot
|
||||
}
|
||||
func (b *Bundle) TraceRoot() string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
return b.traceRoot
|
||||
}
|
||||
func (b *Bundle) RunID() string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(b.path)
|
||||
}
|
||||
func (b *Bundle) CreatedAt() time.Time {
|
||||
if b == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return b.createdAt
|
||||
}
|
||||
func (b *Bundle) Summary() *SummaryWriter {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return &SummaryWriter{root: b.summaryRoot, runID: b.RunID(), createdAt: b.createdAt}
|
||||
}
|
||||
163
internal/core/debugbundle/bundle_test.go
Normal file
163
internal/core/debugbundle/bundle_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package debugbundle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
const testBundleRunID = "run-42-00000000000000000000000000000001"
|
||||
|
||||
func TestAllocateCreatesRestrictiveSummaryAndTrace(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
fixed := time.Unix(0, 42).UTC()
|
||||
bundle, err := Allocate(parent, testBundleRunID, fixed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bundle.RunID() != testBundleRunID || bundle.CreatedAt() != fixed || bundle.SummaryRoot() != filepath.Join(bundle.Path(), "summary") || bundle.TraceRoot() != filepath.Join(bundle.Path(), "trace") {
|
||||
t.Fatalf("bundle=%#v", bundle)
|
||||
}
|
||||
for _, path := range []string{bundle.Path(), bundle.SummaryRoot(), bundle.TraceRoot()} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o700 {
|
||||
t.Fatalf("%s mode=%#o", path, info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
if err := bundle.Summary().WriteError("failed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(bundle.SummaryRoot(), ArtifactErrorLog))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("file mode=%#o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
func TestAllocateRejectsExistingBundleWithoutChangingIt(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
bundlePath := filepath.Join(parent, testBundleRunID)
|
||||
if err := os.Mkdir(bundlePath, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sentinelPath := filepath.Join(bundlePath, "sentinel")
|
||||
sentinel := []byte("existing bundle")
|
||||
if err := os.WriteFile(sentinelPath, sentinel, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := Allocate(parent, testBundleRunID, time.Unix(0, 42)); err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("Allocate() error = %v, want collision", err)
|
||||
}
|
||||
if got, err := os.ReadFile(sentinelPath); err != nil || !bytes.Equal(got, sentinel) {
|
||||
t.Fatalf("sentinel = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateRejectsUnsafeRunIDsBeforeCreatingParent(t *testing.T) {
|
||||
for _, runID := range []string{"", ".", "..", "../escape", `..\\escape`, "/absolute", " trailing "} {
|
||||
t.Run(runID, func(t *testing.T) {
|
||||
parent := filepath.Join(t.TempDir(), "debug")
|
||||
if _, err := Allocate(parent, runID, time.Time{}); err == nil {
|
||||
t.Fatalf("Allocate(%q) succeeded", runID)
|
||||
}
|
||||
if _, err := os.Stat(parent); !os.IsNotExist(err) {
|
||||
t.Fatalf("debug parent exists or stat failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryWriterWritesEverySummaryArtifact(t *testing.T) {
|
||||
bundle, err := Allocate(t.TempDir(), testBundleRunID, time.Unix(0, 42))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summary := bundle.Summary()
|
||||
if err := summary.WriteInvocation(Invocation{Operation: "run"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := summary.WriteRedactedEffectiveConfig(testRedactedSummaryPayload{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := summary.WriteResolvedPipeline(testRedactedResolvedPipelinePayload{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := summary.WriteResolvedReferences(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := summary.WriteCheckpointEvents(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := summary.WriteRunManifest(artifacts.RunManifest{RunID: bundle.RunID()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := summary.WriteChunkPlan(artifacts.ChunkPlanSummary{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := summary.WriteRunReport(RunReport{RunID: bundle.RunID(), PipelineID: "test"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := summary.WriteWarnings([]contracts.Warning{{ReasonCode: "test"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := summary.WriteError("failed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
ArtifactInvocationMetadata,
|
||||
ArtifactEffectiveConfig,
|
||||
ArtifactResolvedPipeline,
|
||||
ArtifactResolvedReferences,
|
||||
ArtifactCheckpointEvents,
|
||||
ArtifactRunManifest,
|
||||
ArtifactChunkPlan,
|
||||
ArtifactRunReport,
|
||||
ArtifactWarnings,
|
||||
ArtifactErrorLog,
|
||||
} {
|
||||
info, err := os.Stat(filepath.Join(bundle.SummaryRoot(), name))
|
||||
if err != nil {
|
||||
t.Fatalf("summary artifact %q: %v", name, err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("summary artifact %q mode=%#o", name, info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestSummaryWriterInternalWritesConfineArtifacts(t *testing.T) {
|
||||
bundle, err := Allocate(t.TempDir(), testBundleRunID, time.Unix(0, 42))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bundle.Summary().writeJSON("../outside.json", map[string]any{}); err == nil {
|
||||
t.Fatal("accepted traversal")
|
||||
}
|
||||
if err := bundle.Summary().writeBytes(`trace\\x`, []byte("x")); err == nil {
|
||||
t.Fatal("accepted backslash")
|
||||
}
|
||||
}
|
||||
|
||||
type testRedactedSummaryPayload struct{}
|
||||
|
||||
func (testRedactedSummaryPayload) RedactedSummaryPayload() any {
|
||||
return map[string]any{"redacted": true}
|
||||
}
|
||||
|
||||
type testRedactedResolvedPipelinePayload struct{}
|
||||
|
||||
func (testRedactedResolvedPipelinePayload) RedactedResolvedPipelinePayload() pipeline.ResolvedPipeline {
|
||||
return pipeline.ResolvedPipeline{ID: "redacted"}
|
||||
}
|
||||
128
internal/core/debugbundle/summary.go
Normal file
128
internal/core/debugbundle/summary.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package debugbundle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
const (
|
||||
ArtifactInvocationMetadata = "invocation.json"
|
||||
ArtifactEffectiveConfig = "effective-config.json"
|
||||
ArtifactResolvedPipeline = "resolved-pipeline.json"
|
||||
ArtifactResolvedReferences = "resolved-references.json"
|
||||
ArtifactCheckpointEvents = "checkpoint-events.json"
|
||||
ArtifactRunManifest = "run-manifest.json"
|
||||
ArtifactChunkPlan = "chunk-plan.json"
|
||||
ArtifactRunReport = "run-report.json"
|
||||
ArtifactWarnings = "warnings.json"
|
||||
ArtifactErrorLog = "error.log"
|
||||
)
|
||||
|
||||
type RedactedSummaryPayload interface{ RedactedSummaryPayload() any }
|
||||
type RedactedResolvedPipelinePayload interface {
|
||||
RedactedResolvedPipelinePayload() pipeline.ResolvedPipeline
|
||||
}
|
||||
type Invocation struct {
|
||||
Operation string `json:"operation"`
|
||||
PipelineID string `json:"pipeline_id,omitempty"`
|
||||
PipelineDigest string `json:"pipeline_digest,omitempty"`
|
||||
Resume bool `json:"resume,omitempty"`
|
||||
RecomputeStep string `json:"recompute_step,omitempty"`
|
||||
InputPath string `json:"input_path,omitempty"`
|
||||
ConfigPath string `json:"config_path,omitempty"`
|
||||
ConfigSource string `json:"config_source,omitempty"`
|
||||
OnlyLanes []string `json:"only_lanes,omitempty"`
|
||||
ChunkCacheOverride string `json:"chunk_cache_override,omitempty"`
|
||||
RunID string `json:"run_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
type RunReport struct {
|
||||
RunID string `json:"run_id"`
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
OutputPath string `json:"output_path,omitempty"`
|
||||
DebugPath string `json:"debug_path,omitempty"`
|
||||
Succeeded bool `json:"succeeded"`
|
||||
OutputCount int `json:"output_count"`
|
||||
RejectedCount int `json:"rejected_count"`
|
||||
WarningCount int `json:"warning_count"`
|
||||
ValidationStatus string `json:"validation_status,omitempty"`
|
||||
}
|
||||
type SummaryWriter struct {
|
||||
root, runID string
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
func (w *SummaryWriter) WriteInvocation(payload Invocation) error {
|
||||
if w == nil {
|
||||
return fmt.Errorf("debug summary writer must not be nil")
|
||||
}
|
||||
if payload.RunID == "" {
|
||||
payload.RunID = w.runID
|
||||
}
|
||||
if payload.StartedAt.IsZero() {
|
||||
payload.StartedAt = w.createdAt
|
||||
}
|
||||
return w.writeJSON(ArtifactInvocationMetadata, payload)
|
||||
}
|
||||
func (w *SummaryWriter) WriteRedactedEffectiveConfig(payload RedactedSummaryPayload) error {
|
||||
if payload == nil {
|
||||
return fmt.Errorf("redacted summary payload must not be nil")
|
||||
}
|
||||
return w.writeJSON(ArtifactEffectiveConfig, payload.RedactedSummaryPayload())
|
||||
}
|
||||
func (w *SummaryWriter) WriteResolvedPipeline(payload RedactedResolvedPipelinePayload) error {
|
||||
if payload == nil {
|
||||
return fmt.Errorf("redacted resolved pipeline payload must not be nil")
|
||||
}
|
||||
return w.writeJSON(ArtifactResolvedPipeline, payload.RedactedResolvedPipelinePayload())
|
||||
}
|
||||
func (w *SummaryWriter) WriteResolvedReferences(v []artifacts.ReferenceProvenance) error {
|
||||
return w.writeJSON(ArtifactResolvedReferences, v)
|
||||
}
|
||||
func (w *SummaryWriter) WriteCheckpointEvents(v []pipeline.CheckpointEvent) error {
|
||||
return w.writeJSON(ArtifactCheckpointEvents, v)
|
||||
}
|
||||
func (w *SummaryWriter) WriteRunManifest(v artifacts.RunManifest) error {
|
||||
return w.writeJSON(ArtifactRunManifest, v)
|
||||
}
|
||||
func (w *SummaryWriter) WriteChunkPlan(v artifacts.ChunkPlanSummary) error {
|
||||
return w.writeJSON(ArtifactChunkPlan, v)
|
||||
}
|
||||
func (w *SummaryWriter) WriteRunReport(v RunReport) error { return w.writeJSON(ArtifactRunReport, v) }
|
||||
func (w *SummaryWriter) WriteWarnings(v []contracts.Warning) error {
|
||||
return w.writeJSON(ArtifactWarnings, v)
|
||||
}
|
||||
func (w *SummaryWriter) WriteError(message string) error {
|
||||
return w.writeBytes(ArtifactErrorLog, []byte(message+"\n"))
|
||||
}
|
||||
func (w *SummaryWriter) writeJSON(name string, v any) error {
|
||||
if w == nil {
|
||||
return fmt.Errorf("debug summary writer must not be nil")
|
||||
}
|
||||
if err := fileio.WriteJSON(w.root, summaryName(name), v, 0o700, 0o600); err != nil {
|
||||
return fmt.Errorf("write debug summary artifact %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (w *SummaryWriter) writeBytes(name string, v []byte) error {
|
||||
if w == nil {
|
||||
return fmt.Errorf("debug summary writer must not be nil")
|
||||
}
|
||||
if err := fileio.WriteBytes(w.root, summaryName(name), v, 0o700, 0o600); err != nil {
|
||||
return fmt.Errorf("write debug summary artifact %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func summaryName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || strings.ContainsAny(name, "/\\") {
|
||||
return "../invalid"
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package diagnostics
|
||||
|
||||
const (
|
||||
ArtifactInvocationMetadata = "invocation.json"
|
||||
ArtifactEffectiveConfig = "effective-config.json"
|
||||
ArtifactResolvedPipeline = "resolved-pipeline.json"
|
||||
ArtifactSourceDocument = "source-document.json"
|
||||
ArtifactRunManifest = "run-manifest.json"
|
||||
ArtifactRunReport = "run-report.json"
|
||||
ArtifactWarnings = "warnings.json"
|
||||
ArtifactErrorLog = "error.log"
|
||||
)
|
||||
@@ -1,22 +0,0 @@
|
||||
package diagnostics
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestArtifactNamesUseExtractionOrientedNames(t *testing.T) {
|
||||
names := []string{
|
||||
ArtifactInvocationMetadata,
|
||||
ArtifactEffectiveConfig,
|
||||
ArtifactResolvedPipeline,
|
||||
ArtifactSourceDocument,
|
||||
ArtifactRunManifest,
|
||||
ArtifactRunReport,
|
||||
ArtifactWarnings,
|
||||
ArtifactErrorLog,
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
if name == "" {
|
||||
t.Fatalf("artifact name must not be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
package diagnostics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWorkDir = "/tmp/notarius"
|
||||
maxRunDirectoryCreateAttempts = 16
|
||||
)
|
||||
|
||||
var utcNow = func() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
// RunDirectory represents a per-run diagnostics directory.
|
||||
type RunDirectory struct {
|
||||
path string
|
||||
retention RetentionMode
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
type RetentionMode string
|
||||
|
||||
const (
|
||||
RetentionAuto RetentionMode = "auto"
|
||||
RetentionAlways RetentionMode = "always"
|
||||
RetentionNever RetentionMode = "never"
|
||||
)
|
||||
|
||||
type RetentionDecisionInput struct {
|
||||
RetentionMode RetentionMode
|
||||
RunSucceeded bool
|
||||
HasWarnings bool
|
||||
}
|
||||
|
||||
type RedactedEffectiveConfigPayload interface {
|
||||
RedactedDiagnosticsPayload() any
|
||||
}
|
||||
|
||||
// InvocationMetadata captures non-secret invocation details for diagnostics.
|
||||
type InvocationMetadata struct {
|
||||
Operation string `json:"operation"`
|
||||
PipelineID string `json:"pipeline_id,omitempty"`
|
||||
PipelineDigest string `json:"pipeline_digest,omitempty"`
|
||||
InputPath string `json:"input_path,omitempty"`
|
||||
ConfigPath string `json:"config_path,omitempty"`
|
||||
ConfigSource string `json:"config_source,omitempty"`
|
||||
OnlyLanes []string `json:"only_lanes,omitempty"`
|
||||
RunID string `json:"run_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
func ShouldRetainRunDirectory(input RetentionDecisionInput) bool {
|
||||
if !input.RunSucceeded {
|
||||
return true
|
||||
}
|
||||
|
||||
switch input.RetentionMode {
|
||||
case RetentionAlways:
|
||||
return true
|
||||
case RetentionNever:
|
||||
return false
|
||||
case RetentionAuto, "":
|
||||
return input.HasWarnings
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func NewRunDirectory(workDir string, retention RetentionMode) (*RunDirectory, error) {
|
||||
if strings.TrimSpace(workDir) == "" {
|
||||
workDir = defaultWorkDir
|
||||
}
|
||||
if retention == "" {
|
||||
retention = RetentionAuto
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create diagnostics work directory %q: %w", workDir, err)
|
||||
}
|
||||
|
||||
var lastRunPath string
|
||||
for attempt := 0; attempt < maxRunDirectoryCreateAttempts; attempt++ {
|
||||
createdAt := utcNow()
|
||||
runID := fmt.Sprintf("run-%d", createdAt.UnixNano())
|
||||
runPath := filepath.Join(workDir, runID)
|
||||
lastRunPath = runPath
|
||||
if err := os.Mkdir(runPath, 0o755); err != nil {
|
||||
if os.IsExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("create diagnostics run directory %q: %w", runPath, err)
|
||||
}
|
||||
|
||||
return &RunDirectory{
|
||||
path: runPath,
|
||||
retention: retention,
|
||||
createdAt: createdAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("create diagnostics run directory %q: exhausted unique run ID attempts", lastRunPath)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) Path() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.path
|
||||
}
|
||||
|
||||
func (r *RunDirectory) RunID() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(r.path)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteInvocationMetadata(metadata InvocationMetadata) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("run directory must not be nil")
|
||||
}
|
||||
if metadata.RunID == "" {
|
||||
metadata.RunID = r.RunID()
|
||||
}
|
||||
if metadata.StartedAt.IsZero() {
|
||||
metadata.StartedAt = r.createdAt
|
||||
}
|
||||
return r.WriteJSONArtifact(ArtifactInvocationMetadata, metadata)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteRedactedEffectiveConfig(payload RedactedEffectiveConfigPayload) error {
|
||||
if payload == nil {
|
||||
return fmt.Errorf("redacted effective config payload must not be nil")
|
||||
}
|
||||
return r.WriteJSONArtifact(ArtifactEffectiveConfig, payload.RedactedDiagnosticsPayload())
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteResolvedPipeline(payload any) error {
|
||||
return r.WriteJSONArtifact(ArtifactResolvedPipeline, payload)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteSourceDocument(payload any) error {
|
||||
return r.WriteJSONArtifact(ArtifactSourceDocument, payload)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteRunManifest(manifest artifacts.RunManifest) error {
|
||||
return r.WriteJSONArtifact(ArtifactRunManifest, manifest)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteRunReport(payload any) error {
|
||||
return r.WriteJSONArtifact(ArtifactRunReport, payload)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteWarnings(warnings []contracts.Warning) error {
|
||||
return r.WriteJSONArtifact(ArtifactWarnings, warnings)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteErrorLog(errorMessage string) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("run directory must not be nil")
|
||||
}
|
||||
path, err := r.artifactPath(ArtifactErrorLog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(path, []byte(errorMessage+"\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("write diagnostics artifact %q: %w", ArtifactErrorLog, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteJSONArtifact(name string, payload any) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("run directory must not be nil")
|
||||
}
|
||||
path, err := r.artifactPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal diagnostics artifact %q: %w", name, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := writeFileAtomic(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write diagnostics artifact %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RunDirectory) ApplyRetention(input RetentionDecisionInput) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("run directory must not be nil")
|
||||
}
|
||||
decision := input
|
||||
if decision.RetentionMode == "" {
|
||||
decision.RetentionMode = r.retention
|
||||
}
|
||||
if ShouldRetainRunDirectory(decision) {
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(r.path); err != nil {
|
||||
return fmt.Errorf("remove diagnostics run directory %q: %w", r.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RunDirectory) artifactPath(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("diagnostics artifact name must not be empty")
|
||||
}
|
||||
if filepath.IsAbs(name) {
|
||||
return "", fmt.Errorf("diagnostics artifact name %q must not be absolute", name)
|
||||
}
|
||||
if name != filepath.Base(name) || strings.Contains(name, "/") || strings.Contains(name, `\`) {
|
||||
return "", fmt.Errorf("diagnostics artifact name %q must not contain path separators", name)
|
||||
}
|
||||
|
||||
runPath, err := filepath.Abs(r.path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve diagnostics run directory %q: %w", r.path, err)
|
||||
}
|
||||
artifactPath, err := filepath.Abs(filepath.Join(runPath, name))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve diagnostics artifact %q: %w", name, err)
|
||||
}
|
||||
if filepath.Dir(artifactPath) != runPath {
|
||||
return "", fmt.Errorf("diagnostics artifact name %q resolves outside run directory", name)
|
||||
}
|
||||
return artifactPath, nil
|
||||
}
|
||||
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
package diagnostics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func TestNewRunDirectoryCreatesRunDirectoryAndRunID(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
runDir, err := NewRunDirectory(workDir, RetentionAuto)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRunDirectory: %v", err)
|
||||
}
|
||||
|
||||
if filepath.Dir(runDir.Path()) != workDir {
|
||||
t.Fatalf("unexpected run directory parent: %q", runDir.Path())
|
||||
}
|
||||
if ok := regexp.MustCompile(`^run-\d+$`).MatchString(runDir.RunID()); !ok {
|
||||
t.Fatalf("unexpected run ID: %q", runDir.RunID())
|
||||
}
|
||||
info, err := os.Stat(runDir.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("stat run directory: %v", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Fatalf("expected run path to be a directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunDirectoryRetriesOnRunIDCollision(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
first := time.Unix(0, 100).UTC()
|
||||
second := first.Add(time.Nanosecond)
|
||||
if err := os.Mkdir(filepath.Join(workDir, fmt.Sprintf("run-%d", first.UnixNano())), 0o755); err != nil {
|
||||
t.Fatalf("create existing run directory: %v", err)
|
||||
}
|
||||
restoreUTCNow := replaceUTCNow(func() func() time.Time {
|
||||
calls := 0
|
||||
return func() time.Time {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return first
|
||||
}
|
||||
return second
|
||||
}
|
||||
}())
|
||||
t.Cleanup(restoreUTCNow)
|
||||
|
||||
runDir, err := NewRunDirectory(workDir, RetentionAuto)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRunDirectory: %v", err)
|
||||
}
|
||||
|
||||
wantRunID := fmt.Sprintf("run-%d", second.UnixNano())
|
||||
if runDir.RunID() != wantRunID {
|
||||
t.Fatalf("RunID = %q, want %q", runDir.RunID(), wantRunID)
|
||||
}
|
||||
if _, err := os.Stat(runDir.Path()); err != nil {
|
||||
t.Fatalf("stat run directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunDirectoryReturnsErrorAfterRunIDCollisionsExhausted(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
collisionTime := time.Unix(0, 200).UTC()
|
||||
collisionPath := filepath.Join(workDir, fmt.Sprintf("run-%d", collisionTime.UnixNano()))
|
||||
if err := os.Mkdir(collisionPath, 0o755); err != nil {
|
||||
t.Fatalf("create existing run directory: %v", err)
|
||||
}
|
||||
restoreUTCNow := replaceUTCNow(func() time.Time {
|
||||
return collisionTime
|
||||
})
|
||||
t.Cleanup(restoreUTCNow)
|
||||
|
||||
_, err := NewRunDirectory(workDir, RetentionAuto)
|
||||
if err == nil || !strings.Contains(err.Error(), "exhausted unique run ID attempts") {
|
||||
t.Fatalf("expected exhausted collision error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunDirectoryUsesDefaultWorkDirectory(t *testing.T) {
|
||||
runDir, err := NewRunDirectory("", RetentionAuto)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRunDirectory: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(runDir.Path())
|
||||
_ = os.Remove(defaultWorkDir)
|
||||
})
|
||||
|
||||
if filepath.Dir(runDir.Path()) != defaultWorkDir {
|
||||
t.Fatalf("expected default work directory %q, got %q", defaultWorkDir, filepath.Dir(runDir.Path()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONArtifactWritesIndentedNewlineTerminatedJSON(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
if err := runDir.WriteJSONArtifact("artifact.json", map[string]any{"value": "ok"}); err != nil {
|
||||
t.Fatalf("WriteJSONArtifact: %v", err)
|
||||
}
|
||||
|
||||
data := readArtifact(t, runDir, "artifact.json")
|
||||
if !strings.HasSuffix(string(data), "\n") {
|
||||
t.Fatalf("expected trailing newline, got %q", data)
|
||||
}
|
||||
if !strings.Contains(string(data), "\n \"value\": \"ok\"\n") {
|
||||
t.Fatalf("expected indented JSON, got %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONArtifactLeavesNoTemporaryFiles(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
if err := runDir.WriteJSONArtifact("artifact.json", map[string]any{"value": "ok"}); err != nil {
|
||||
t.Fatalf("WriteJSONArtifact: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(runDir.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("read run directory: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry.Name(), ".tmp-") {
|
||||
t.Fatalf("temporary diagnostics file remains after success: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteInvocationMetadataFillsMissingRunIDAndStartTime(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
if err := runDir.WriteInvocationMetadata(InvocationMetadata{Operation: "validate"}); err != nil {
|
||||
t.Fatalf("WriteInvocationMetadata: %v", err)
|
||||
}
|
||||
|
||||
var got InvocationMetadata
|
||||
if err := json.Unmarshal(readArtifact(t, runDir, ArtifactInvocationMetadata), &got); err != nil {
|
||||
t.Fatalf("unmarshal invocation metadata: %v", err)
|
||||
}
|
||||
if got.RunID != runDir.RunID() {
|
||||
t.Fatalf("unexpected run ID: got %q want %q", got.RunID, runDir.RunID())
|
||||
}
|
||||
if got.StartedAt.IsZero() {
|
||||
t.Fatalf("expected started_at to be filled")
|
||||
}
|
||||
if got.Operation != "validate" {
|
||||
t.Fatalf("unexpected operation: %q", got.Operation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteInvocationMetadataPreservesProvidedRunIDAndStartTime(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
startedAt := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := runDir.WriteInvocationMetadata(InvocationMetadata{
|
||||
Operation: "validate",
|
||||
RunID: "provided",
|
||||
StartedAt: startedAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteInvocationMetadata: %v", err)
|
||||
}
|
||||
|
||||
var got InvocationMetadata
|
||||
if err := json.Unmarshal(readArtifact(t, runDir, ArtifactInvocationMetadata), &got); err != nil {
|
||||
t.Fatalf("unmarshal invocation metadata: %v", err)
|
||||
}
|
||||
if got.RunID != "provided" {
|
||||
t.Fatalf("unexpected run ID: %q", got.RunID)
|
||||
}
|
||||
if !got.StartedAt.Equal(startedAt) {
|
||||
t.Fatalf("unexpected started_at: %s", got.StartedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteTypedArtifacts(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
if err := runDir.WriteRedactedEffectiveConfig(fakeRedactedEffectiveConfig{payload: map[string]any{"redacted": true}}); err != nil {
|
||||
t.Fatalf("WriteRedactedEffectiveConfig: %v", err)
|
||||
}
|
||||
if err := runDir.WriteResolvedPipeline(map[string]any{"pipeline": "test"}); err != nil {
|
||||
t.Fatalf("WriteResolvedPipeline: %v", err)
|
||||
}
|
||||
if err := runDir.WriteSourceDocument(map[string]any{"source_id": "source-1"}); err != nil {
|
||||
t.Fatalf("WriteSourceDocument: %v", err)
|
||||
}
|
||||
if err := runDir.WriteRunManifest(artifacts.RunManifest{RunID: "run-1"}); err != nil {
|
||||
t.Fatalf("WriteRunManifest: %v", err)
|
||||
}
|
||||
if err := runDir.WriteRunReport(map[string]any{"ok": true}); err != nil {
|
||||
t.Fatalf("WriteRunReport: %v", err)
|
||||
}
|
||||
if err := runDir.WriteWarnings([]contracts.Warning{{ReasonCode: "test", Message: "warning"}}); err != nil {
|
||||
t.Fatalf("WriteWarnings: %v", err)
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
ArtifactEffectiveConfig,
|
||||
ArtifactResolvedPipeline,
|
||||
ArtifactSourceDocument,
|
||||
ArtifactRunManifest,
|
||||
ArtifactRunReport,
|
||||
ArtifactWarnings,
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(runDir.Path(), name)); err != nil {
|
||||
t.Fatalf("expected artifact %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRedactedEffectiveConfigWritesPayloadReturnedByProvider(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
if err := runDir.WriteRedactedEffectiveConfig(fakeRedactedEffectiveConfig{
|
||||
payload: map[string]any{
|
||||
"api_key": "[REDACTED]",
|
||||
"model": "test-model",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteRedactedEffectiveConfig: %v", err)
|
||||
}
|
||||
|
||||
data := string(readArtifact(t, runDir, ArtifactEffectiveConfig))
|
||||
if !strings.Contains(data, `"api_key": "[REDACTED]"`) || !strings.Contains(data, `"model": "test-model"`) {
|
||||
t.Fatalf("unexpected effective config artifact: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorLogWritesPlainTextWithTrailingNewline(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
if err := runDir.WriteErrorLog("something failed"); err != nil {
|
||||
t.Fatalf("WriteErrorLog: %v", err)
|
||||
}
|
||||
|
||||
if got := string(readArtifact(t, runDir, ArtifactErrorLog)); got != "something failed\n" {
|
||||
t.Fatalf("unexpected error log: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactPathRejectsUnsafeNames(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
tests := []string{
|
||||
"",
|
||||
" ",
|
||||
"/absolute.json",
|
||||
"nested/artifact.json",
|
||||
`nested\artifact.json`,
|
||||
"../escape.json",
|
||||
}
|
||||
|
||||
for _, name := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := runDir.WriteJSONArtifact(name, map[string]any{}); err == nil {
|
||||
t.Fatalf("expected unsafe artifact name %q to be rejected", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRetainRunDirectoryDecisions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input RetentionDecisionInput
|
||||
want bool
|
||||
}{
|
||||
{name: "failed auto retained", input: RetentionDecisionInput{RetentionMode: RetentionAuto, RunSucceeded: false}, want: true},
|
||||
{name: "failed always retained", input: RetentionDecisionInput{RetentionMode: RetentionAlways, RunSucceeded: false}, want: true},
|
||||
{name: "failed never retained", input: RetentionDecisionInput{RetentionMode: RetentionNever, RunSucceeded: false}, want: true},
|
||||
{name: "successful always retained", input: RetentionDecisionInput{RetentionMode: RetentionAlways, RunSucceeded: true}, want: true},
|
||||
{name: "successful never removed", input: RetentionDecisionInput{RetentionMode: RetentionNever, RunSucceeded: true}, want: false},
|
||||
{name: "successful auto without warnings removed", input: RetentionDecisionInput{RetentionMode: RetentionAuto, RunSucceeded: true}, want: false},
|
||||
{name: "successful auto with warnings retained", input: RetentionDecisionInput{RetentionMode: RetentionAuto, RunSucceeded: true, HasWarnings: true}, want: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := ShouldRetainRunDirectory(tc.input); got != tc.want {
|
||||
t.Fatalf("ShouldRetainRunDirectory() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRetentionRemovesOnlyRunDirectory(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
runDir, err := NewRunDirectory(workDir, RetentionNever)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRunDirectory: %v", err)
|
||||
}
|
||||
siblingPath := filepath.Join(workDir, "sibling")
|
||||
if err := os.WriteFile(siblingPath, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatalf("write sibling: %v", err)
|
||||
}
|
||||
|
||||
if err := runDir.ApplyRetention(RetentionDecisionInput{RunSucceeded: true}); err != nil {
|
||||
t.Fatalf("ApplyRetention: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(runDir.Path()); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected run directory removed, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(workDir); err != nil {
|
||||
t.Fatalf("expected work directory retained: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(siblingPath); err != nil {
|
||||
t.Fatalf("expected sibling retained: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRetentionKeepsRetainedRunDirectory(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
if err := runDir.ApplyRetention(RetentionDecisionInput{RunSucceeded: true, HasWarnings: true}); err != nil {
|
||||
t.Fatalf("ApplyRetention: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(runDir.Path()); err != nil {
|
||||
t.Fatalf("expected run directory retained: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRunDirectory(t *testing.T) *RunDirectory {
|
||||
t.Helper()
|
||||
runDir, err := NewRunDirectory(t.TempDir(), RetentionAuto)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRunDirectory: %v", err)
|
||||
}
|
||||
return runDir
|
||||
}
|
||||
|
||||
func replaceUTCNow(replacement func() time.Time) func() {
|
||||
original := utcNow
|
||||
utcNow = replacement
|
||||
return func() {
|
||||
utcNow = original
|
||||
}
|
||||
}
|
||||
|
||||
func readArtifact(t *testing.T, runDir *RunDirectory, name string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(runDir.Path(), name))
|
||||
if err != nil {
|
||||
t.Fatalf("read artifact %q: %v", name, err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
type fakeRedactedEffectiveConfig struct {
|
||||
payload any
|
||||
}
|
||||
|
||||
func (f fakeRedactedEffectiveConfig) RedactedDiagnosticsPayload() any {
|
||||
return f.payload
|
||||
}
|
||||
133
internal/core/fileio/fileio.go
Normal file
133
internal/core/fileio/fileio.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Package fileio provides confined, atomic artifact writes.
|
||||
package fileio
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SafePath(root, name string) (string, error) {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return "", fmt.Errorf("file root must not be empty")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("artifact name must not be empty")
|
||||
}
|
||||
if strings.ContainsRune(name, '\\') {
|
||||
return "", fmt.Errorf("artifact name %q must use slash-separated relative paths", name)
|
||||
}
|
||||
if path.IsAbs(name) || filepath.IsAbs(name) {
|
||||
return "", fmt.Errorf("artifact name %q must be relative", name)
|
||||
}
|
||||
if name == "." || strings.Contains(name, "..") {
|
||||
return "", fmt.Errorf("artifact name %q must not contain ..", name)
|
||||
}
|
||||
if path.Clean(name) != name {
|
||||
return "", fmt.Errorf("artifact name %q must be clean", name)
|
||||
}
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve file root %q: %w", root, err)
|
||||
}
|
||||
target, err := filepath.Abs(filepath.Join(absRoot, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve artifact %q: %w", name, err)
|
||||
}
|
||||
rel, err := filepath.Rel(absRoot, target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve artifact %q: %w", name, err)
|
||||
}
|
||||
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("artifact name %q resolves outside file root", name)
|
||||
}
|
||||
if err := rejectSymlinkComponents(absRoot, name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func WriteJSON(root, name string, payload any, dirMode, fileMode os.FileMode) error {
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal artifact %q: %w", name, err)
|
||||
}
|
||||
return WriteBytes(root, name, append(data, '\n'), dirMode, fileMode)
|
||||
}
|
||||
|
||||
func WriteBytes(root, name string, data []byte, dirMode, fileMode os.FileMode) error {
|
||||
target, err := SafePath(root, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), dirMode); err != nil {
|
||||
return fmt.Errorf("write artifact %q: %w", name, err)
|
||||
}
|
||||
if err := rejectSymlinkComponents(root, name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(target, data, fileMode); err != nil {
|
||||
return fmt.Errorf("write artifact %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectSymlinkComponents(root, name string) error {
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve file root %q: %w", root, err)
|
||||
}
|
||||
current := absRoot
|
||||
for _, component := range strings.Split(filepath.FromSlash(name), string(filepath.Separator)) {
|
||||
if component == "" || component == "." {
|
||||
continue
|
||||
}
|
||||
current = filepath.Join(current, component)
|
||||
info, err := os.Lstat(current)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("inspect artifact path %q: %w", name, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("artifact path %q must not traverse symbolic links", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeAtomic(target string, data []byte, fileMode os.FileMode) error {
|
||||
temp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
keep := true
|
||||
defer func() {
|
||||
if keep {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Chmod(fileMode); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempPath, target); err != nil {
|
||||
return err
|
||||
}
|
||||
keep = false
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user