prregress

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 18, 2026 License: AGPL-3.0 Imports: 16 Imported by: 0

Documentation

Overview

Package prregress implements PR-based regression evaluation: given a merged PR, check out the world *before* it landed, build a ckv index over that snapshot, hand the PR's Background to an agent, and compare the agent's plan against what the PR actually did.

Why this exists: ckv eval's recall@k / MRR metrics measure retrieval quality on synthetic queries. They do NOT show whether the system helps an agent reach the right answer end-to-end. PR-regression closes that gap — every passing PR is empirical proof that an agent armed with ckv would have proposed something close to what shipped.

Module layout (one file per concern, all in this package):

types.go    — data model + fixture loader (this file)
fetcher.go  — gh CLI metadata fetch (PR title/body/files)
checkout.go — detached git worktree at base_sha
ground.go   — PR diff → changed file set (truth)
agent.go    — Claude CLI headless plan generation
score.go    — LLM-as-judge + file-set F1
runner.go   — flow orchestration over a Fixture

Build tag none — uses standard library + gh CLI subprocess + git. Test tag prregress_smoke for the end-to-end PR #70 test that depends on the source repo + Claude CLI + network access being available.

Index

Constants

View Source
const DefaultThreshold = 0.80

DefaultThreshold is the minimum LLM-judge similarity score that counts as "agent reproduced the intent of the PR."

View Source
const FixtureSchemaVersion = "1"

FixtureSchemaVersion is the YAML schema version this package reads/writes. Bump on incompatible changes.

Variables

This section is empty.

Functions

func ExtractBackground

func ExtractBackground(body string) string

ExtractBackground pulls the "Background" section out of a PR description. If the section isn't present (some teams don't use the pattern), the whole body is returned as a conservative fallback — better to give the agent too much than to give it nothing.

The boundary is "the next header at the same or higher level." Practically: "### Background" runs until the next "### " or "## " or "# ". Anything beyond that header (Solution, Changes, etc.) is stripped so the agent doesn't peek at the answer.

func ExtractExpectedFiles

func ExtractExpectedFiles(markdown string) []string

ExtractExpectedFiles parses the "Expected Changes" section of a markdown plan and returns the file paths the agent listed.

Robust to:

  • Different header levels (`##`, `###`, `**Expected Changes**`).
  • Singular vs plural ("Expected File", "Expected Changes", etc.).
  • Various bullet markers (`-`, `*`, `+`).
  • A trailing colon and the agent's free-form description after the path.

Returned slice is deduplicated but order-preserving (first occurrence wins) so the agent's ranking is observable in logs. The caller can sort downstream when needed.

func ExtractPlanSteps

func ExtractPlanSteps(markdown string) string

ExtractPlanSteps returns the plan narrative with the "Expected Changes" tail removed so the file list does not dilute step-level token overlap (FileSetF1 already covers that axis). When no "Expected Changes" header is present, the whole markdown is returned unchanged.

func ExtractPlanSymbols

func ExtractPlanSymbols(markdown string) []string

ExtractPlanSymbols pulls likely code-symbol references out of a plan markdown. Order-preserving, dedup. Feeds SymbolF1 alongside the hand-curated Entry.ChangedSymbols truth.

Limitation: regex is heuristic — it picks up Type names mentioned in prose (e.g. "modify ServerConfig"), missed by AST-diff but useful as a first-pass signal. A future iteration could swap to a tree-sitter pass over fenced code blocks for precision.

func FetchDiff

func FetchDiff(ctx context.Context, e Entry) (string, error)

FetchDiff pulls the PR's unified diff via `gh pr diff`. This is the "actual code change" the judge compares the agent's plan against.

gh returns the diff as one large text blob (all files concatenated, standard git diff format). The score layer caps how much of this gets embedded in the judge prompt, so callers don't need to chunk upstream.

func FileSetF1

func FileSetF1(plan, truth []string) (precision, recall, f1 float64)

FileSetF1 computes precision / recall / F1 of two file path sets. Comparison is path-string equality after normalizing separators (the gh API returns POSIX paths; git on macOS/Linux already uses them). Empty truth → 0; empty plan with non-empty truth → 0/0/0.

func IntentCosine

func IntentCosine(ctx context.Context, plan, reference string, e types.Embedder) (float64, error)

IntentCosine returns the embedding cosine similarity between two strings. Optional companion to IntentScore — runs only when the caller supplies an embedder (typically the same instance the runner uses for index build, reused for free).

Mock embedders satisfy the interface but their vectors are random hashes of the input; cosine over those is essentially noise. Callers who want a meaningful number must pass a real embedder (e.g. bgeonnx).

Returns NaN-safe value clamped to [-1, 1]. Empty inputs → 0.

func IntentScore

func IntentScore(plan, reference string) float64

IntentScore measures how well a plan's intent narrative overlaps with a reference intent statement (Entry.IntentGroundTruth when present, else Meta.Title). Unigram token-F1 after lowercase + alphanum/Hangul split + minimal stopword strip. Returns 0..1.

Empty plan or empty reference → 0 (the metric requires both sides). Identical token sets → 1. Disjoint → 0.

Paraphrase invariance is intentionally NOT a goal here: the determinism of token-F1 is the point — reruns produce identical numbers, which is what regression detection needs. The cosine variant (IntentCosine) handles paraphrase when an embedder is available.

func PlanStepsScore

func PlanStepsScore(planSteps, commitMessages string) float64

PlanStepsScore compares the plan's step narrative (markdown body sans the "Expected Changes" file list) against the PR's commit-message log. Same token-F1 algorithm as IntentScore but applied to longer strings.

Empty commitMessages → 0; the metric requires both sides. The caller is responsible for joining the multi-commit messages into one string before passing it in (the runner does this once).

func SortedFiles

func SortedFiles(in []string) []string

SortedFiles returns a stable-order copy of the file path slice. Used to keep JSON output diffable across reruns.

func SummarizeResults

func SummarizeResults(results []Result, threshold float64) string

SummarizeResults formats a batch outcome for the CLI report. Plain text by default; JSON is the caller's responsibility (just encode the []Result slice).

func SymbolF1

func SymbolF1(planSymbols, truthSymbols []string) (precision, recall, f1 float64)

SymbolF1 mirrors FileSetF1 for symbol identifiers extracted from the plan vs Entry.ChangedSymbols. Normalization: lowercase, runs of non-alphanum collapse to a single `_`, leading/trailing `_` trimmed.

Empty truthSymbols → 0/0/0 (matches FileSetF1's contract). The descriptive truth entries in the fixture ("secp256r1 precompile registration", "systemcontracts init parsers — members") will not match PascalCase plan symbols — that low recall is the intended signal until AST-diff symbol extraction lands.

func TruthFiles

func TruthFiles(m Meta) []string

TruthFiles returns the canonical set of files the PR actually changed — used as the ground truth for file-set F1 scoring.

The list comes straight from gh CLI's `files` field, which mirrors what GitHub UI shows on the "Files changed" tab. Pure additions and pure deletions both count. Test files count too: if a PR ships a fix without test coverage that's a real signal an agent's plan can miss.

Why a separate function instead of just reading Meta.Files: future follow-ups will filter (e.g. exclude generated files, normalize renames). Centralizing the projection keeps the runner ignorant of those decisions.

Types

type ChangedFile

type ChangedFile struct {
	Path      string `json:"path"`
	Additions int    `json:"additions"`
	Deletions int    `json:"deletions"`
}

ChangedFile is one file the PR touched, as reported by gh CLI. Line-level diff parsing is deferred to a follow-up (symbol-set F1 would need it; file-set F1 only needs the path).

type DeterministicScorer

type DeterministicScorer struct{}

DeterministicScorer computes every pure-Go metric in the Score (file-set F1, intent token-F1, symbol F1, plan-steps overlap) with NO LLM call — the binary = deterministic half of pr-regression scoring (00 §2.2). It leaves the LLM-judge fields (JudgeScore/JudgeRaw) zero; the agent/session layer injects a JudgeScorer that adds those when an LLM judge is wanted. The diff argument is accepted to satisfy JudgeScorer but is unused here (the deterministic metrics compare plan vs. PR metadata, not raw diff text).

func (DeterministicScorer) Score

func (DeterministicScorer) Score(_ context.Context, e Entry, m Meta, plan Plan, _ string) (Score, error)

Score computes the deterministic plan-vs-PR metrics. Each multi-stage metric is guarded so legacy fixture rows (no IntentGroundTruth, ChangedSymbols, or Commits) emit zero/omitempty fields instead of false positives. IntentCosine is populated separately by RunEntry when a real embedder is configured (see runner.go).

type Entry

type Entry struct {
	ID                string   `yaml:"id"`
	Repo              string   `yaml:"repo"` // owner/name
	PRNumber          int      `yaml:"pr_number"`
	SourcePath        string   `yaml:"source_path"` // local clone path
	BaseSHA           string   `yaml:"base_sha"`
	Threshold         float64  `yaml:"threshold,omitempty"`
	Notes             string   `yaml:"notes,omitempty"`
	IntentGroundTruth string   `yaml:"intent_ground_truth,omitempty"`
	ChangedSymbols    []string `yaml:"changed_symbols,omitempty"`
	Category          string   `yaml:"category,omitempty"`
}

Entry is one PR's fixture row. Fields match testdata/prs.yaml; see that file's header for semantics.

IntentGroundTruth / ChangedSymbols / Category were added in the fixture expansion (4 → 12) so the multi-stage evaluation (E1 intent / E2 symbol-level location) has structured ground truth. All three are optional — legacy entries (pr69 / pr70 / pr72 / pr74) load without them and the score.go consumers fall back to file-set F1 when symbols are absent.

type Fixture

type Fixture struct {
	SchemaVersion string  `yaml:"schema_version"`
	PRs           []Entry `yaml:"prs"`
}

Fixture is the parsed `testdata/prs.yaml` document.

func LoadFixture

func LoadFixture(path string) (*Fixture, error)

LoadFixture reads + validates a PR fixture YAML. Validation is fail-loud: any missing required field on any entry is an error, not a warning, because a malformed entry corrupts the regression report silently.

Portability: every Entry.SourcePath is run through os.ExpandEnv before validation so the YAML can ship with a machine-agnostic `${CKV_STABLENET_PATH}` (or any env-var) placeholder instead of a hard-coded absolute path. Unset placeholders surface as a helpful error during validate() rather than silently expanding to "".

type JudgeScorer

type JudgeScorer interface {
	Score(ctx context.Context, e Entry, m Meta, plan Plan, diff string) (Score, error)
}

JudgeScorer evaluates how closely a Plan's intent matches the actual PR change. The combined Score carries the deterministic file/intent/ symbol/plan-step metrics; an agent-layer implementation may also fill the LLM-judge fields (JudgeScore/JudgeRaw). The threshold gate reads JudgeScore, so a deterministic-only run (JudgeScore=0) never "passes" the LLM gate — that's intentional: LLM judging is the agent layer's job.

type Meta

type Meta struct {
	Title          string        `json:"title"`
	Body           string        `json:"body"`                      // full PR description (Background + Solution + Changes)
	Background     string        `json:"background"`                // extracted: the "what's wrong" piece, agent sees this
	Files          []ChangedFile `json:"files"`                     // truth: what the PR actually changed
	CommitMessages []string      `json:"commit_messages,omitempty"` // E3: ground truth for plan-step decomposition (headline + body, one entry per commit)
}

Meta is the slice of GitHub PR data the harness consumes. Captured once via gh CLI and passed forward; we deliberately don't re-fetch during scoring to keep the regression deterministic across reruns.

func FetchMeta

func FetchMeta(ctx context.Context, e Entry) (Meta, error)

FetchMeta gets the PR's title, body, and changed-file list from GitHub via the `gh` CLI. The user must have run `gh auth login` once; we do not embed a GitHub token.

We deliberately shell out to `gh` instead of using a Go GitHub client because:

  • `gh` already manages auth (token in OS keyring, scope renewal)
  • it follows the user's GH_HOST / enterprise config
  • one binary call is cheaper than pulling in an SDK

Returned Body is the full PR description. Background is the extracted "what's wrong" piece — that's the only thing we hand to the agent (Solution and Changes stay hidden).

type Plan

type Plan struct {
	Markdown      string   `json:"markdown"`
	ExpectedFiles []string `json:"expected_files"`
}

Plan is what the agent produced after seeing only Background. Markdown is the free-form rationale; ExpectedFiles is the structured section the agent is asked to emit at the end (parser pulls it back out for file-set F1 scoring).

type PlanAgent

type PlanAgent interface {
	Generate(ctx context.Context, e Entry, m Meta, hints []query.Hit) (Plan, error)
}

PlanAgent turns a problem description (PR Background) + ckv search hits into a markdown implementation plan. The agent never sees the PR's Solution / Changes — that's the gap we're measuring.

The interface is the seam where LLM work crosses out of this binary (00 §2.2: binary = deterministic). The ckv binary ships NO concrete PlanAgent — plan generation is inherently an LLM task, so the agent/session layer injects its own implementation. The plan it returns is parsed back into structured form by ExtractExpectedFiles, which stays here as a deterministic, tested parser.

type Result

type Result struct {
	Entry Entry  `json:"entry"`
	Meta  Meta   `json:"meta"`
	Plan  Plan   `json:"plan"`
	Score Score  `json:"score"`
	Pass  bool   `json:"pass"`            // judge_score >= entry.threshold
	Error string `json:"error,omitempty"` // first-error wins; downstream stages skip
}

Result is one Entry's outcome. Persisted to disk for report generation.

func RunEntry

func RunEntry(ctx context.Context, e Entry, opts *RunOptions) Result

RunEntry executes the six-stage flow for one PR. Errors from any stage are folded into Result.Error; later stages skip but the pre-computed parts of Score / Plan / Meta still appear in the report so the user can see how far the pipeline got.

  1. fetch PR metadata (gh CLI)
  2. create detached git worktree at base_sha
  3. ckv build over the worktree
  4. ckv query (Background as the intent) → top-K hints
  5. agent generates plan (Background + hints)
  6. fetch PR diff + score (plan vs diff)

func RunFixture

func RunFixture(ctx context.Context, fx *Fixture, opts *RunOptions) ([]Result, error)

RunFixture executes RunEntry for every PR in fx. Results come back in the same order as fx.PRs. Individual failures don't abort the batch — each Result captures its own Error string.

type RunOptions

type RunOptions struct {
	// Embedder is used to build the per-PR index and answer hint
	// queries. Required.
	Embedder types.Embedder
	// EmbedderName is the value Embedder.Name() returns, captured so
	// post-run reports can label which model produced the score.
	EmbedderName string

	// Agent generates the implementation plan from PR Background +
	// search hints. REQUIRED — the binary ships no concrete PlanAgent
	// (plan generation is inherently LLM work, injected by the agent
	// layer). fill() errors when nil.
	Agent PlanAgent
	// Scorer grades the plan against the PR. Defaults to
	// DeterministicScorer{} (file/intent/symbol/plan-step metrics, no
	// LLM). The agent layer may inject an LLM-judge scorer instead.
	Scorer JudgeScorer

	// TopK is how many ckv hits to hand the agent as "candidates worth
	// looking at." Defaults to 10.
	TopK int

	// IndexBase is the parent directory under which per-PR ckv index
	// dirs are created. Defaults to $TMPDIR. Each entry creates
	// <IndexBase>/ckv-prregress-<id>-<sha[:12]>/index/.
	IndexBase string
}

RunOptions configure how RunFixture / RunEntry operate. The caller must supply an Embedder and a PlanAgent (plan generation is LLM work, excised from the binary per 00 §2.2). Scorer defaults to the deterministic metric scorer; top-K defaults to 10; the index is built into a temp dir beside the worktree.

type Score

type Score struct {
	JudgeScore float64 `json:"judge_score"` // 0..1, LLM rubric output (E3 + E4 combined, legacy)
	JudgeRaw   string  `json:"judge_raw,omitempty"`
	JudgeError string  `json:"judge_error,omitempty"`

	FileF1        float64 `json:"file_f1"`
	FilePrecision float64 `json:"file_precision"`
	FileRecall    float64 `json:"file_recall"`

	PlanFiles  []string `json:"plan_files"`
	TruthFiles []string `json:"truth_files"`

	// Multi-stage E1/E2/E3 metrics
	IntentScore     float64  `json:"intent_score,omitempty"`     // E1: pure-Go token-F1 vs IntentGroundTruth || Title
	IntentCosine    float64  `json:"intent_cosine,omitempty"`    // E1 (optional): embedder cosine, populated only when RunOptions.Embedder is real
	IntentError     string   `json:"intent_error,omitempty"`     // E1 cosine subprocess error, if any
	SymbolPrecision float64  `json:"symbol_precision,omitempty"` // E2
	SymbolRecall    float64  `json:"symbol_recall,omitempty"`    // E2
	SymbolF1        float64  `json:"symbol_f1,omitempty"`        // E2
	PlanSymbols     []string `json:"plan_symbols,omitempty"`     // E2 evidence (extracted from plan)
	TruthSymbols    []string `json:"truth_symbols,omitempty"`    // E2 evidence (= Entry.ChangedSymbols)
	PlanStepsScore  float64  `json:"plan_steps_score,omitempty"` // E3
}

Score combines the LLM-as-judge score and file-set F1 with the multi-stage decomposition (E1 intent / E2 symbol-level location / E3 plan-step decomposition). All new fields are omitempty so legacy fixture rows without IntentGroundTruth / ChangedSymbols / commit data still produce a clean Score with only the original axes populated.

type Verdict

type Verdict struct {
	Score     float64 `json:"score"`
	Rationale string  `json:"rationale"`
}

Verdict is the parsed judge JSON. Score is float [0..1].

func ExtractJudgeVerdict

func ExtractJudgeVerdict(out []byte) (Verdict, bool)

ExtractJudgeVerdict pulls the first {score, rationale} object out of LLM output. Tolerant to:

  • ``` fences (json or plain)
  • leading/trailing prose ("Here's my analysis: { ... }")
  • score out of range — clamped to [0, 1], not rejected

Returns ok=false if no parseable object found.

type Worktree

type Worktree struct {
	Path    string // absolute path of the worktree directory
	BaseSHA string // resolved full SHA (in case the fixture used a prefix)
	// contains filtered or unexported fields
}

Worktree is a detached `git worktree` pointing at base_sha. The original clone's checkout / branch / HEAD is not touched, so the user can keep working on whatever they had checked out.

func CreateWorktree

func CreateWorktree(ctx context.Context, e Entry) (*Worktree, error)

CreateWorktree spins up a detached worktree at base_sha. Returns a Worktree handle whose Close() removes the worktree (the underlying commits stay in the clone's objects/).

The worktree path is deterministic per (sourcePath, baseSHA), so reruns reuse the same directory and avoid unbounded /tmp growth. We do NOT cache across runs to avoid "stale index" surprises — the caller deletes it via Close() on every eval.

func (*Worktree) Close

func (w *Worktree) Close() error

Close removes the worktree. Idempotent.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL