Skip to content

Commit 214013f

Browse files
ankrgylclutchskiclaude
authored
Fuzz testing for chat completions (#84)
- use fuzz to generate random payloads - verify openai => universal => openai works - snapshot fuzz cases, and keep the fuzz generator ignored --------- Co-authored-by: Matt Perpick <matt@braintrustdata.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a7c516e commit 214013f

130 files changed

Lines changed: 6144 additions & 536 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/commands/fuzz-fix.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Fix a fuzz roundtrip issue
2+
3+
Run `make -C crates/lingua/tests/fuzz run` to get a minimal failing case. Show the issue to the user, validate it with a real request, and fix with TDD.

.claude/settings.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"hooks": {
3+
"PostToolUse": [
4+
{
5+
"matcher": "Edit|Write|NotebookEdit",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"command": "cargo fmt"
10+
}
11+
]
12+
}
13+
]
14+
}
15+
}

.github/workflows/ci.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,26 @@ env:
1010
CARGO_TERM_COLOR: always
1111

1212
jobs:
13+
typed-boundary:
14+
if: github.event_name == 'pull_request'
15+
runs-on: ubuntu-latest
16+
17+
steps:
18+
- uses: actions/checkout@v4
19+
with:
20+
fetch-depth: 0
21+
22+
- name: Fetch base branch
23+
run: git fetch --no-tags --prune --depth=1 origin "${{ github.base_ref }}"
24+
25+
- name: Install ripgrep
26+
run: |
27+
sudo apt-get update
28+
sudo apt-get install -y ripgrep
29+
30+
- name: Enforce typed boundary invariant
31+
run: make typed-boundary-check-branch BASE=origin/${{ github.base_ref }}
32+
1333
test:
1434
runs-on: ubuntu-latest
1535

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ dev-notes/
1111
*.node
1212
.turbo
1313
.env
14-
.claude
14+
.claude/*local*.json

CLAUDE.md renamed to AGENTS.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ Lingua is a universal message format that compiles to provider-specific formats
1313
- **Type safety**: Full TypeScript and Rust type generation with bidirectional validation
1414
- **No network calls**: This is a message format library, not an API client
1515
- **Explicit error handling**: All errors must be properly handled, never silently swallowed
16+
- **No hidden marker fields**: Do not encode provider semantics via internal marker keys (for example in `provider_options`) to fake lossless roundtrips.
17+
- **Ask when non-lossy mapping is unclear**: If the universal type cannot represent a provider feature non-lossily, stop and ask for clarification on the intended canonical representation before implementing a workaround.
18+
- **No unapproved fallback logic**: Do not add ad-hoc fallback parsing/translation paths (for example `fallback_*` helpers) without checking with the programmer first.
19+
- **Typed boundaries only**: At provider boundaries, parse into well-defined typed structs/enums. Do not add lenient raw-JSON parsing that guesses defaults for required fields (for example defaulting missing `role` to `user`, lowercasing unknown roles, or inventing empty `content`).
20+
- **Fix via types or explicit errors**: If fuzzing finds unsupported/ambiguous shapes, either model them explicitly in types/converters or return a clear error. Do not silently coerce invalid input into a "best effort" shape.
21+
- **Typed-boundary CI gate**: CI enforces `make typed-boundary-check-branch BASE=origin/<base-branch>` on pull requests. Running `make typed-boundary-check` locally is recommended for faster feedback, but not required as a pre-commit hook.
22+
- **Typed extras views over raw map access**: If provider extras must be read, deserialize extras into a typed view struct first; do not pluck fields ad-hoc with `map.get(...)`.
1623

1724
## Documentation style guide
1825

@@ -428,4 +435,4 @@ Update README.md:
428435
- **Document differences**: Note any provider-specific quirks or limitations
429436
- **Consider streaming**: Many providers support streaming responses
430437

431-
This process ensures consistent provider integration while maintaining type safety and zero-runtime overhead.
438+
This process ensures consistent provider integration while maintaining type safety and zero-runtime overhead.

CONTRIBUTING.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Contributing
2+
3+
## Fuzz Snapshot Workflow
4+
5+
Use the top-level make target to refresh fuzz snapshots:
6+
7+
```bash
8+
make fuzz-snapshots
9+
```
10+
11+
What it does:
12+
13+
1. Runs ignored fuzz tests that generate/update snapshots:
14+
`openai_roundtrip`, `openai_roundtrip_stats`
15+
`responses_roundtrip`, `responses_roundtrip_stats`
16+
`anthropic_roundtrip`, `anthropic_roundtrip_stats`
17+
`chat_anthropic_two_arm`, `chat_anthropic_two_arm_stats`
18+
`chat_responses_anthropic_three_arm`, `chat_responses_anthropic_three_arm_stats`
19+
2. Runs snapshot prune/dedupe:
20+
`openai_roundtrip_prune_snapshots`
21+
`responses_roundtrip_prune_snapshots`
22+
`anthropic_roundtrip_prune_snapshots`
23+
`chat_anthropic_two_arm_prune_snapshots`
24+
`chat_responses_anthropic_three_arm_prune_snapshots`
25+
26+
Notes:
27+
28+
- The generation tests may fail while still producing useful snapshots; this is expected in this workflow.
29+
- Prune is conservative: it removes malformed/orphan files and dedupes failures by normalized reason.
30+
31+
If you only want to dedupe/prune existing fuzz snapshots:
32+
33+
```bash
34+
make fuzz-snapshots-prune
35+
```

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Makefile

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: all lingua-wasm typescript python test test-payloads capture capture-transforms clean help generate-types generate-all-providers install-hooks install-wasm-tools setup precommit
1+
.PHONY: all lingua-wasm typescript python test test-payloads capture capture-transforms clean help generate-types generate-all-providers install-hooks install-wasm-tools setup precommit fuzz-snapshots fuzz-snapshots-prune typed-boundary-check typed-boundary-check-branch
22

33
all: typescript python ## Build all bindings
44

@@ -58,6 +58,38 @@ test-payloads: lingua-wasm ## Run payload transform tests (REGENERATE=1 to auto-
5858
@cd payloads && pnpm vitest run scripts/transforms $(if $(REGENERATE),|| pnpm tsx scripts/regenerate-failed.ts) \
5959
|| (echo "\n❌ Tests failed! Run 'make regenerate-failed-transforms' to regenerate with real API calls" && exit 1)
6060

61+
fuzz-snapshots: ## Run ignored fuzz tests to generate/update snapshots, then prune duplicate failures
62+
@$(MAKE) -C crates/lingua/tests/fuzz refresh-snapshots
63+
64+
fuzz-snapshots-prune: ## Prune fuzz snapshots (dedupe by normalized failure reason)
65+
@$(MAKE) -C crates/lingua/tests/fuzz prune
66+
67+
typed-boundary-check: ## Fail if local provider/universal edits add direct Value field access
68+
@echo "Checking typed boundary regressions in local changes..."; \
69+
RESULTS=$$(git diff --unified=0 -- crates/lingua/src/providers crates/lingua/src/universal | \
70+
rg -N '^\+.*(\.get\(\"|\.as_object\(|\.as_array\(|\.as_str\(|\.as_i64\(|\.as_u64\(|\.as_bool\(|\.as_f64\()' || true); \
71+
if [ -n "$$RESULTS" ]; then \
72+
echo "Found direct Value access in added lines:"; \
73+
printf '%s\n' "$$RESULTS"; \
74+
exit 1; \
75+
fi; \
76+
echo "No new direct Value field access detected."
77+
78+
typed-boundary-check-branch: ## Fail if committed branch diff adds direct Value field access (usage: make typed-boundary-check-branch BASE=fuzz-test-anthropic)
79+
@if [ -z "$(BASE)" ]; then \
80+
echo "Usage: make typed-boundary-check-branch BASE=<comparison-branch>"; \
81+
exit 1; \
82+
fi; \
83+
echo "Checking typed boundary regressions in committed diff against $(BASE)..."; \
84+
RESULTS=$$(git diff --unified=0 "$(BASE)"...HEAD -- crates/lingua/src/providers crates/lingua/src/universal | \
85+
rg -N '^\+.*(\.get\(\"|\.as_object\(|\.as_array\(|\.as_str\(|\.as_i64\(|\.as_u64\(|\.as_bool\(|\.as_f64\()' || true); \
86+
if [ -n "$$RESULTS" ]; then \
87+
echo "Found direct Value access in added lines:"; \
88+
printf '%s\n' "$$RESULTS"; \
89+
exit 1; \
90+
fi; \
91+
echo "No new direct Value field access detected in committed diff."
92+
6193
capture: lingua-wasm ## Capture payloads (snapshots + transforms + vitest snapshots)
6294
cd payloads && pnpm capture $(if $(FILTER),--filter $(FILTER)) $(if $(CASES),--cases $(CASES)) $(if $(FORCE),--force)
6395

@@ -67,10 +99,10 @@ capture-transforms: lingua-wasm ## Re-capture only transforms (e.g. make capture
6799
regenerate-failed-transforms: lingua-wasm ## Auto-regenerate failed transform payloads
68100
cd payloads && pnpm tsx scripts/regenerate-failed.ts
69101

70-
test-python: ## Run Python tests
102+
test-python: python ## Run Python tests
71103
@echo "Running Python tests..."
72-
cd bindings/python && uv run maturin develop --features python
73-
cd bindings/python && uv run pytest tests/ -v
104+
cd bindings/python && uv run --extra dev --group dev maturin develop --features python
105+
cd bindings/python && uv run --with pytest python -m pytest tests/ -v
74106

75107
clean: ## Clean build artifacts
76108
@echo "Cleaning build artifacts..."
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2+
3+
/**
4+
* Provider identity for built-in tool passthrough.
5+
*/
6+
export type BuiltinToolProvider = "anthropic" | "responses" | "google" | "converse";

bindings/typescript/src/generated/Message.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ import type { AssistantContent } from "./AssistantContent";
33
import type { ToolContentPart } from "./ToolContentPart";
44
import type { UserContent } from "./UserContent";
55

6-
export type Message = { "role": "system", content: UserContent, } | { "role": "user", content: UserContent, } | { "role": "assistant", content: AssistantContent, id?: string, } | { "role": "tool", content: Array<ToolContentPart>, };
6+
export type Message = { "role": "system", content: UserContent, } | { "role": "developer", content: UserContent, } | { "role": "user", content: UserContent, } | { "role": "assistant", content: AssistantContent, id?: string, } | { "role": "tool", content: Array<ToolContentPart>, };

0 commit comments

Comments
 (0)