Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
LOCAL.md
.env
.coherence/
.hegel/
.claude/ralph-loop.local.md
bin/
.chant/
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ chant init [--force] [--json] # scaffold chant.yml, recipes/, skill, gitig
chant index [--no-registry] [--json] # rebuild .chant/index.json (+ upsert into the per-machine registry)
chant status [--json] # rewrite .chant/STATUS.md
chant doctor [--json] # validate config + store
chant bench [--suite=retrieval|e2e|all] [--json] # run the validation suite
chant bench [--suite=retrieval|e2e|properties|all] [--json] # run the validation suite
chant version
chant help
```
Expand Down Expand Up @@ -532,17 +532,24 @@ go test ./...
## Bench

`chant bench` runs the shipped validation suites and exits 1 on any failure —
same CI muscle memory as `coherence bench`:
same CI muscle memory as `coherence bench`. The default `all` suite is the
fast gate (`retrieval` + `e2e`); Hegel properties are opt-in:

```bash
chant bench # default: all suites
chant bench --suite=retrieval # retrieval ranking scenarios (incl. true negatives)
chant bench --suite=e2e # run + verify every recipe with an example, asserting the trust gate
chant bench --suite=properties # Hegel property tests; starts hegel-core
chant bench --json # machine-readable
```

The retrieval suite asserts which recipe ranks first for a query (and that an
unrelated query yields **no** match). The e2e suite proves the verifier-first
gate end to end: each recipe with a verifier and an example is run and verified,
and is only counted as a pass when the verifier establishes trust. See
and is only counted as a pass when the verifier establishes trust. The
properties suite uses Hegel under the `hegel` build tag to generate cases for
retrieval, runner, spell-hash, and CSV recipe invariants; Hegel state is kept
under `.chant/hegel/`, with the latest report at
`.chant/hegel/properties-report.json` and failure payloads under
`.chant/hegel/failures/`. See
[`internal/bench/bench.go`](internal/bench/bench.go).
20 changes: 16 additions & 4 deletions cmd/chant/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -1177,7 +1177,7 @@ func cmdDoctor(args []string) error {

func cmdBench(args []string) error {
fs := flag.NewFlagSet("bench", flag.ContinueOnError)
suite := fs.String("suite", "all", "retrieval | e2e | all")
suite := fs.String("suite", "all", "retrieval | e2e | properties | all")
asJSON := fs.Bool("json", false, "emit JSON")
if err := fs.Parse(args); err != nil {
return err
Expand All @@ -1187,15 +1187,27 @@ func cmdBench(args []string) error {
return err
}
var summaries []bench.Summary
if *suite == "retrieval" || *suite == "all" {
switch *suite {
case "retrieval":
summaries = append(summaries, bench.RunRetrieval(s.Config.Retrieval))
case "e2e":
e2e, err := bench.RunE2E(s)
if err != nil {
return err
}
summaries = append(summaries, e2e)
case "properties":
summaries = append(summaries, bench.RunProperties(s.Root))
case "all":
// Keep all as the fast, backward-compatible gate: retrieval + e2e.
summaries = append(summaries, bench.RunRetrieval(s.Config.Retrieval))
}
if *suite == "e2e" || *suite == "all" {
e2e, err := bench.RunE2E(s)
if err != nil {
return err
}
summaries = append(summaries, e2e)
default:
return fmt.Errorf("unknown bench suite %q (want retrieval, e2e, properties, or all)", *suite)
}
failed := 0
for _, sum := range summaries {
Expand Down
2 changes: 1 addition & 1 deletion cmd/chant/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Repo:
index [--no-registry] rebuild .chant/index.json (and upsert into the per-machine registry)
status rewrite .chant/STATUS.md
doctor validate config + store
bench [--suite=retrieval|e2e|all] run the validation suite
bench [--suite=retrieval|e2e|properties|all] run the validation suite
version

Most commands accept --json for a stable machine-readable outcome contract.
Expand Down
20 changes: 20 additions & 0 deletions cmd/chant/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,26 @@ func TestCLI_JSONErrorEmitsBlockingError(t *testing.T) {
}
}

func TestCLI_BenchUnknownSuiteJSONError(t *testing.T) {
bin := buildBinary(t)
repo := newRepo(t)
out, code := run(t, bin, repo, "bench", "--suite=bogus", "--json")
if code != 1 {
t.Errorf("expected exit 1 for unknown bench suite, got %d\n%s", code, out)
}
var e struct {
BlockingError bool `json:"blocking_error"`
Message string `json:"message"`
Subcommand string `json:"subcommand"`
}
if err := json.Unmarshal([]byte(out), &e); err != nil {
t.Fatalf("bench error path did not emit JSON under --json: %v\n%s", err, out)
}
if !e.BlockingError || e.Subcommand != "bench" || !strings.Contains(e.Message, "unknown bench suite") {
t.Errorf("unexpected bench error JSON: %+v\n%s", e, out)
}
}

// TestCLI_SuggestEmptyLibraryMatchFound verifies match_found is always present
// (false), even with no recipes, so agents can gate on it unconditionally.
func TestCLI_SuggestEmptyLibraryMatchFound(t *testing.T) {
Expand Down
11 changes: 9 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ A chant recipe is also called an **enchantment** (synonym; the on-disk form is
docs/
├── README.md ← you are here
├── commands/ ← one page per CLI command
├── integration/ ← integration notes such as Hegel
└── specs/ ← design specs (owned by the lead; see enchantment-metadata.md)
```

Expand Down Expand Up @@ -129,16 +130,22 @@ graph edges. The canonical design is

## Bench

Every page's behavior is exercised by `chant bench`, which ships two suites:
Every page's behavior is exercised by `chant bench`. The default `all` suite
runs the two fast compatibility suites:

- **retrieval** — synthetic recipe set + queries asserting which recipe ranks
first (including true negatives: an unrelated query must NOT match).
- **e2e** — runs each recipe's procedure + verifier and asserts the
verifier-first gate (trusted only after the verifier passes).
- **properties** — opt-in Hegel property tests for retrieval, runner,
spell-hash, and CSV recipe invariants, with per-property case counts and
`.chant/hegel/` evidence files.

```bash
chant bench --suite=all
chant bench --suite=properties
```

Exit code is `1` when any scenario fails — same CI muscle memory as
`coherence bench`. See [`commands/bench.md`](commands/bench.md).
`coherence bench`. See [`commands/bench.md`](commands/bench.md) and
[`integration/hegel.md`](integration/hegel.md).
50 changes: 45 additions & 5 deletions docs/commands/bench.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
## What it does

Runs chant's shipped validation suites and exits `1` on any scenario failure —
the same CI muscle memory as `coherence bench`. Two suites prove the core
thesis:
the same CI muscle memory as `coherence bench`. The default `all` suite is the
fast compatibility gate: `retrieval` + `e2e`. The Hegel-backed `properties`
suite is opt-in because it starts the Hegel runtime.

- **`retrieval`** — a synthetic recipe set + queries, asserting which recipe
ranks first and whether it clears the match threshold. Includes a **true
Expand All @@ -18,12 +19,18 @@ thesis:
verifier establishes trust). Recipes without a verifier or without an example
are skipped (reported as a pass with a skip note) so the suite stays green on
a fresh library.
- **`properties`** — runs build-tagged Hegel property tests for retrieval,
runner trust, spell hashes, and the CSV recipe oracle. Results are reported
per property with a `cases` count, and `.chant/hegel/properties-report.json`
preserves the latest report. On failure, generated-case evidence is written
under `.chant/hegel/failures/`. The suite may use Python/`uv` to start
`hegel-core` unless `HEGEL_SERVER_COMMAND` is set.

## Flags

| Flag | Default | Meaning |
|---|---|---|
| `--suite` | `all` | `retrieval`, `e2e`, or `all`. |
| `--suite` | `all` | `retrieval`, `e2e`, `properties`, or `all`. `all` intentionally excludes `properties`. |
| `--json` | false | emit the suite summaries as JSON. |

## Example (human)
Expand Down Expand Up @@ -89,8 +96,36 @@ chant bench --json

A top-level `{summaries[], failed}`. Each `Summary` carries `suite`, `total`,
`passed`, `failed`, and `results[]`; each `Result` carries `id`, `name`,
`suite`, `pass`, and a `detail` string. `failed` is the total failed across all
suites; a non-zero value makes `chant bench` exit `1`.
`suite`, `pass`, and a `detail` string. Property results may also carry
`cases` and `failure_artifact`. `failed` is the total failed across all suites;
a non-zero value makes `chant bench` exit `1`.

The opt-in property suite has the same top-level JSON shape:

```bash
chant bench --suite=properties --json
```

```json
{
"failed": 0,
"summaries": [
{
"suite": "properties",
"total": 5,
"passed": 5,
"failed": 0,
"results": [
{"id": "PROP-retrieval-stale-penalty", "name": "retrieval stale penalty", "suite": "properties", "pass": true, "detail": "Hegel property passed", "cases": 50},
{"id": "PROP-retrieval-signal-monotonicity", "name": "retrieval signal monotonicity", "suite": "properties", "pass": true, "detail": "Hegel property passed", "cases": 50},
{"id": "PROP-runner-trust-gate", "name": "runner trust gate", "suite": "properties", "pass": true, "detail": "Hegel property passed", "cases": 50},
{"id": "PROP-spell-hash-stability", "name": "spell hash stability", "suite": "properties", "pass": true, "detail": "Hegel property passed", "cases": 50},
{"id": "PROP-csv-recipe-oracle", "name": "CSV recipe oracle", "suite": "properties", "pass": true, "detail": "Hegel property passed", "cases": 25}
]
}
]
}
```

## What the scenarios assert

Expand All @@ -101,3 +136,8 @@ suites; a non-zero value makes `chant bench` exit `1`.
| `RET-003` | a chargeback/refund task routes to `refund-chargeback-threat`. |
| `RET-004` | column signals disambiguate revenue from a normalize recipe. |
| `E2E-<id>` | each recipe with a verifier + example runs and verifies, and is trusted only on a passing verifier. |
| `PROP-retrieval-stale-penalty` | Hegel generates cases proving stale recipes score exactly half and carry a stale warning. |
| `PROP-retrieval-signal-monotonicity` | Hegel generates matching and unsatisfied structural signals and checks score behavior. |
| `PROP-runner-trust-gate` | Hegel generates verifier/artifact combinations and checks trust is granted only through the gate. |
| `PROP-spell-hash-stability` | Hegel generates equivalent commands and column orderings and checks `spell_hash` stability. |
| `PROP-csv-recipe-oracle` | Hegel generates CSV rows/aliases and compares the recipe output to an independent oracle. |
90 changes: 90 additions & 0 deletions docs/integration/hegel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Hegel integration

Hegel is chant's opt-in generative verifier layer. It does not add a new trust
path: `chant verify` still trusts a recipe only when `verification.command`
exits 0 and every expected artifact exists.

## How it fits

Use Hegel when a recipe or internal invariant depends on input-shape variation:
schema aliases, edge-case values, stale status, artifact combinations, or
portable identity. Hegel supplies generated cases and shrinking; chant supplies
the recipe, command execution, and verifier-first trust gate.

For a recipe, keep using the existing verifier field:

```yaml
verification:
command: go test ./property -run TestCSVRevenueProperty -count=1
expected_artifacts:
- property-report.json
```

The verifier command can run any Hegel client. In this Go repo, use
`hegel.dev/go/hegel` and ordinary `go test`.

## Built-in property suite

chant ships an opt-in Hegel suite:

```bash
chant bench --suite=properties
chant bench --suite=properties --json
```

The default `chant bench` / `chant bench --suite=all` path intentionally stays
fast and does not run Hegel. The property suite runs build-tagged tests with:

```bash
go test -tags hegel ./internal/bench -run TestHegelProperties -count=1
```

`chant bench --suite=properties --json` reports each property separately,
including its generated case count. It also writes the latest evidence summary
to `.chant/hegel/properties-report.json`.

The suite covers:

- `PROP-retrieval-stale-penalty` — 50 cases.
- `PROP-retrieval-signal-monotonicity` — 50 cases.
- `PROP-runner-trust-gate` — 50 cases.
- `PROP-spell-hash-stability` — 50 cases.
- `PROP-csv-recipe-oracle` — 25 cases.

On failure, the Hegel test writes the generated case payload to
`.chant/hegel/failures/<property-id>.json`. Hegel's own shrunk failure output is
still emitted through `go test`; the chant artifact preserves the generated
case data that caused the property to fail.

## Hermetic CI

Keep Hegel opt-in. For CI, preinstall the matching `hegel-core` binary and wire
the command explicitly rather than relying on runtime `uv` installation:

```yaml
- name: Hegel properties
run: chant bench --suite=properties --json
env:
HEGEL_SERVER_COMMAND: /path/to/preinstalled/hegel
```

## Runtime notes

Hegel is currently beta, so chant pins the Go client version. Hegel starts a
`hegel-core` server at runtime; by default the client uses Python/`uv` to fetch
and run the compatible server. In hermetic CI, preinstall `hegel-core` and set:

```bash
export HEGEL_SERVER_COMMAND=/path/to/hegel
```

chant sets `CHANT_HEGEL_DIR` for the built-in suite so Hegel state lives under
`.chant/hegel/`. `.hegel/` is also gitignored as a fallback for Hegel server
logs.

References:

- [How Hegel works](https://hegel.dev/explanation/how-hegel-works)
- [Installation reference](https://hegel.dev/reference/installation)
- [Compatibility](https://hegel.dev/compatibility)
- [hegel-go API](https://pkg.go.dev/hegel.dev/go/hegel)
10 changes: 9 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,12 @@ module github.com/fireharp/chant

go 1.26.3

require gopkg.in/yaml.v3 v3.0.1 // indirect
require (
gopkg.in/yaml.v3 v3.0.1
hegel.dev/go/hegel v0.5.3
)

require (
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
)
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
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=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
hegel.dev/go/hegel v0.5.3 h1:qd3C6pbrwC1SJSVojbPxMPRbPKlFPbLQL7HTLcbFceE=
hegel.dev/go/hegel v0.5.3/go.mod h1:Y0rTUM/9YtGOj0poljehZ92IWW0Fv2qf47oqZzhzrXE=
12 changes: 7 additions & 5 deletions internal/bench/bench.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ import (

// Result is one scenario outcome.
type Result struct {
ID string `json:"id"`
Name string `json:"name"`
Suite string `json:"suite"`
Pass bool `json:"pass"`
Detail string `json:"detail"`
ID string `json:"id"`
Name string `json:"name"`
Suite string `json:"suite"`
Pass bool `json:"pass"`
Detail string `json:"detail"`
Cases int `json:"cases,omitempty"`
FailureArtifact string `json:"failure_artifact,omitempty"`
}

// Summary aggregates a bench run.
Expand Down
Loading
Loading