Skip to content

Add deterministic experiment decisions - #54978

Merged
pelikhan merged 8 commits into
mainfrom
copilot/deterministic-experiment-decisions
Aug 23, 2026
Merged

pelikhan merged 8 commits into
mainfrom
copilot/deterministic-experiment-decisions

Conversation

Copilot AI commented Aug 23, 2026 •

Copy link
Copy Markdown
Contributor

Experiment analysis previously stopped at readiness and statistical comparisons. This adds a deterministic, machine-readable policy layer that recommends EXTEND, PROMOTE, REJECT, or INCONCLUSIVE.

Changes

  • Decision engine

    • Adds a pure ExperimentAnalysis → ExperimentDecisionResult transformation.
    • Applies sample readiness, guardrails, practical effect, statistical evidence, and metric direction in deterministic precedence.
    • Supports frequentist and Bayesian analyses without rerunning statistics.
  • Decision policy

    • Adds absolute minimum_effect, regression_tolerance, and confidence settings.
    • Normalizes effects so positive values always favor the candidate.
experiments:
  prompt_v2:
    variants: [control, candidate]
    metric: grader:trajectory-efficiency
    analysis_type: mann_whitney
    decision:
      minimum_effect: 0.05
      regression_tolerance: 0.05
      confidence: 0.95
  • Guardrails

    • Resolves grader- and eval-backed guardrail observations.
    • Rejects candidates when mandatory guardrails fail.
    • Extends experiments when guardrail observations are missing or insufficient.
  • CLI and JSON

    • Adds the final decision and reason to human-readable analysis output.
    • Adds decision, reason_code, control, candidate, samples, effect, evidence, decision_guardrails, and decision_policy to JSON output.
    • Preserves existing analysis and recommendation fields.
  • Scope

    • Automatic decisions require one control and one candidate.
    • Multi-variant experiments return INCONCLUSIVE.
    • Native metrics without resolved observations return EXTEND.
    • Decisions do not mutate workflows, traffic, or experiment state.

Run: https://github.com/github/gh-aw/actions/runs/32618879655> Generated by 👨‍🍳 PR Sous Chef · gpt54 · 6.44 AIC · ⌖ 8.16 AIC · ⊞ 9.5K · ◷

Comment /souschef to run again


Run: https://github.com/github/gh-aw/actions/runs/32620039467> Generated by 👨‍🍳 PR Sous Chef · gpt54 · 32.1 AIC · ⌖ 10 AIC · ⊞ 7.1K · ◷

Comment /souschef to run again

Copilot AI and others added 2 commits August 23, 2026 03:29
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
@pelikhan
pelikhan marked this pull request as ready for review August 23, 2026 04:08
Copilot AI balanced review requested due to automatic review settings August 23, 2026 04:08
@github-actions

github-actions Bot commented Aug 23, 2026 •

Copy link
Copy Markdown
Contributor

✅ PR Code Quality Reviewer completed the code quality review.

🔎 Code quality review by PR Code Quality Reviewer

@github-actions

github-actions Bot commented Aug 23, 2026 •

Copy link
Copy Markdown
Contributor

✅ Test Quality Sentinel completed test quality analysis.

Test Quality Sentinel skipped because pre-fetch PR data was unavailable: unable to fetch test file diff

🧪 Test quality analysis by Test Quality Sentinel

@github-actions

github-actions Bot commented Aug 23, 2026 •

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer

@github-actions

github-actions Bot commented Aug 23, 2026 •

Copy link
Copy Markdown
Contributor

✅ Design Decision Gate 🏗️ completed the design decision gate check.

🏗️ ADR gate enforced by Design Decision Gate 🏗️

@github-actions

github-actions Bot commented Aug 23, 2026 •

Copy link
Copy Markdown
Contributor

✅ Ponytail Reviewer completed successfully!

Generated by Ponytail Reviewer for #54978

@github-actions

Copy link
Copy Markdown
Contributor

Comment Memory

reviewed_at: 2026-08-23T00:00:00Z
review_event: REQUEST_CHANGES
top_themes:
  - guardrail unresolved state can force perpetual EXTEND
  - decision_guardrails payload is contradictory when no guardrails exist
files_reviewed:
  - pkg/cli/experiments_analyze_statistics.go
  - pkg/cli/experiments_command.go
  - pkg/cli/experiments_decision.go
  - pkg/cli/experiments_eval_observations.go
  - pkg/cli/experiments_grader_observations.go
  - pkg/workflow/compiler_experiments.go
  - pkg/workflow/frontmatter_types.go
comment_count: 2

Note

This comment is managed by comment memory.

It stores persistent context for this thread in the code block at the top of this comment.
Edit only the text inside the backtick fences; workflow metadata and the footer are regenerated automatically.

Learn more about comment memory

🔎 Code quality review by PR Code Quality Reviewer · gpt54 · 29.4 AIC · ⌖ 7.01 AIC · ⊞ 7K · ◷
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES

The decision layer is close, but the new guardrail plumbing still has blocking correctness holes: unresolved guardrails can permanently force EXTEND, and the machine-readable guardrail summary reports passed: true even when no guardrails exist.

Blocking themes
  • Guardrail status defaults leak into the decision engine and can make some experiments impossible to conclude.
  • The JSON contract now emits contradictory guardrail state for experiments with no configured guardrails.

I ignored the background sub-agent output because it produced no findings.

🔎 Code quality review by PR Code Quality Reviewer · gpt54 · 29.4 AIC · ⌖ 7.01 AIC · ⊞ 7K
Comment /review to run again

}
applyExperimentBalance(&a, exp.Name, exp.Total, cfg, variantCounts, variantNames, expectedPcts)
applyExperimentReadiness(&a, graderObservations != nil)
applyExperimentGuardrails(&a, guardrailObservations)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This marks every configured guardrail as unresolved when the experiment only has assignment counts, because applyExperimentGuardrails leaves the default unsupported status in place and DecideExperiment treats any unsupported guardrail as an automatic EXTEND. That means native/non-observation-backed metrics can never reach INCONCLUSIVE or any final decision once a guardrail is configured, even after min_samples is met.

💡 Why this blocks merge

The spec says guardrails should extend only when observations are missing or insufficient, not when the analyzer simply doesn't have a per-run metric source for that guardrail. Right now the flow is:

applyExperimentGuardrails(&a, guardrailObservations) // no-op when observations are nil
// guardrail.Status stays "unsupported"
...
pendingGuardrailDecision(...) => "guardrail_unsupported" => EXTEND

So a config like a native primary metric plus any guardrail will never terminate. Either leave unresolved guardrails out of the blocking path unless a metric source was actually attempted, or introduce a distinct "not_evaluated" state that does not force EXTEND.

}
return ExperimentDecisionResult{
Direction: analysis.MetricDirection,
Guardrails: ExperimentDecisionGuardrails{Configured: len(analysis.Guardrails) > 0, Passed: guardrailPassSummary(analysis.Guardrails)},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This leaks a fake “all guardrails passed” result into the JSON output when no guardrails are configured, because guardrailPassSummary returns true for the empty slice while Configured is false. Consumers now have to special-case a contradictory payload (configured: false, passed: true) instead of treating the absence of guardrails as “not applicable”.

💡 Why this matters

The current constructor builds:

Guardrails: ExperimentDecisionGuardrails{
    Configured: len(analysis.Guardrails) > 0,
    Passed:     guardrailPassSummary(analysis.Guardrails),
}

and guardrailPassSummary returns &true for len == 0. That encodes a semantic lie into the machine-readable API and makes downstream automation harder to reason about. Return nil when no guardrails exist, and only emit passed once the experiment actually has mandatory guardrails to evaluate.

@github-actions

Copy link
Copy Markdown
Contributor

Test Quality Sentinel Analysis 🧪

PR: #54978 — "Add deterministic experiment decisions"

Summary

This PR adds deterministic experiment decision logic with comprehensive test coverage. The new features introduce decision-making rules for experiment promotions, rejections, and extensions based on statistical evidence and guardrail validation.

Test Coverage Overview

File Type Tests Coverage
pkg/cli/experiments_decision_test.go NEW 2 functions, 13 scenarios Decision logic, guardrails
pkg/workflow/compiler_experiment_decision_test.go NEW 1 function Config parsing
pkg/cli/experiments_analyze_statistics_test.go Modified +4 assertions JSON output validation
pkg/workflow/compiler_experiments_test.go Modified +3 scenarios Guardrail validation
pkg/cli/experiments_grader_observations_test.go Modified 1 fix API signature update

Test Metrics

New Behavioral Tests: 16 scenarios across 3 new test functions

Metric Value Status
Design Tests (contract-based) 16/16 ✅ 100%
Edge-Case Coverage 14/16 ✅ 87.5%
Implementation Tests 0/16 ✅ 0%
Table-Driven Rows 13 ✅ Core logic
Error Assertions 5+ ✅ Comprehensive
Mocking Violations 0 ✅ Clean
Missing Build Tags 0 ✅ Compliant

Test-to-Code Ratio

File Pair Test Lines Prod Lines Ratio Assessment
experiments_decision_test.go (+260) experiments_decision.go (+281) 0.92:1 ✅ Balanced
compiler_experiment_decision_test.go (+32) (New logic in workflow) ~0.3:1 ✅ Focused

All ratios are healthy — no test inflation detected.

Detailed Test Analysis

New Test Functions (Click to expand)

1. TestDecideExperiment — Decision Engine (13 table-driven scenarios)

Design Invariants Tested:

  • Direction-aware decisions (maximize vs. minimize metrics)
  • Material effect thresholds (reject tiny improvements)
  • Statistical confidence levels (p-value and Bayesian thresholds)
  • Guardrail blocking (reject if any guardrail fails)
  • Extension logic (insufficient samples or missing guardrails)
  • Multi-variant rejection (explicitly unsupported)

Coverage Highlights:

  • ✅ Promotion scenarios (max direction +0.2, min direction -1200): material improvement detection
  • ✅ Regression detection (max -0.2, min +1200): proper normalization
  • ✅ Statistical thresholds (p=0.01 passes, p=0.20 fails): confidence-based decisions
  • ✅ Bayesian variants (0.98 probability vs. 0.80): alternate analysis framework
  • ✅ Guardrail guards (fail, missing, pass): blocking and extension logic
  • ✅ Edge case (multi-variant): explicit rejection with reason code

Assertions: 4 per scenario (Decision, ReasonCode, Message, optional Effect normalization)
Value: High — each scenario tests a specific behavioral decision path


2. TestApplyExperimentGuardrails — Guardrail Evaluation (3 scenarios)

Design Invariants Tested:

  • Direction-aware comparisons (max: higher is better, min: lower is better)
  • Observation sufficiency (reject if missing observations)
  • Pass/fail/insufficient_observations classification

Coverage Highlights:

  • ✅ Pass scenario (max, both at 1.0): guardrail passes correctly
  • ✅ Fail scenario (min, candidate > control): regression detection
  • ✅ Missing observations (unbalanced counts): sufficiency check

Assertions: 2–3 per scenario (Status, Passed boolean, nil check)
Value: High — guardrails are a design contract for safety


3. TestExtractExperimentDecisionConfig — Schema Parsing (1 scenario)

Design Invariant Tested:

  • Frontmatter decision section properly parses decision policy (minimum_effect, regression_tolerance, confidence)

Coverage:

  • ✅ Nested config extraction with tolerance as optional pointer
  • ✅ Numeric validation (0.05, 0.10, 0.99)

Assertions: 3 (NotNil, InDelta × 2)
Value: High — schema parsing is a contract boundary

Helper Functions & Test Infrastructure

The PR includes well-designed helper functions supporting test parameterization without inflating test count:

  • decisionTestAnalysis() — builds analysis with below-min-samples flag
  • readyDecisionTestAnalysis() — builds ready-to-decide analysis with configurable direction/delta/confidence
  • withDecisionTestGuardrail() — augments analysis with guardrail
  • withThirdDecisionTestVariant() — tests multi-variant rejection
  • floatPointer(), boolPointer() — pointer helpers

Impact: Helpers enable 13 scenarios without duplicating setup code.

Edge Cases Covered ✅

  1. Below-minimum-samples → EXTEND
  2. Material improvement with strong evidence → PROMOTE
  3. Tiny effect despite significance → INCONCLUSIVE
  4. Large effect without evidence → INCONCLUSIVE
  5. Bayesian analysis → alternate pathway
  6. Guardrail failures → block promotion
  7. Missing guardrails → EXTEND
  8. Multi-variant → unsupported
  9. Regression detection → directional
  10. Normalized effect → absolute value correctness

Code Quality ✅

  • Proper (go/redacted):build !integration tags
  • t.Parallel() for efficiency
  • Clear assertion messages
  • No forbidden mocks
  • Strong require/assert discipline
  • Realistic test data
  • Surgical modifications to existing tests

Quality Score

Design tests: 16/16 = 100% → 40 pts
Edge cases: 14/16 = 87.5% → 26.25 pts
Duplicates: 0 clusters → 20 pts
Inflation: 0.92:1 ratio ✓ → 10 pts
────────────────────────
Total: 96/100 ✅ EXCELLENT

Verdict

Status: ✅ APPROVE

Score: 96/100 — Excellent

Rationale:

  • All 16 new scenarios verify design contracts
  • 87.5% include edge-case assertions
  • Zero implementation-detail tests
  • Balanced test-to-code ratio (0.92:1)
  • No violations (clean build tags, no mocks, proper parallelization)
  • Surgical existing-test extensions

This PR demonstrates strong test discipline for a complex feature. Decision engine invariants are comprehensively validated, guardrail logic thoroughly tested, and schema parsing verified.


Implementation-Test Ratio: 0% (threshold: 30%) ✅
Coding Violations: 0 ✅

🧪 Test quality analysis by Test Quality Sentinel · haiku45 · 26.4 AIC · ⌖ 5.34 AIC · ⊞ 8.3K · ◷
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Test Quality Sentinel: 96/100 — Excellent

All 16 new test scenarios verify design contracts. Edge-case coverage at 87.5%. Zero implementation-detail tests. Test-to-code ratio: 0.92:1 (balanced). No guideline violations.

Implementation-Test Ratio: 0% (threshold: 30%) ✅

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ponytail review — two thin pass-through wrappers found; no other over-engineering spotted in the diff.

net: -20 lines possible.

Generated by ✂️ Ponytail Reviewer for #54978 · auto · 57.3 AIC · ⌖ 5.67 AIC · ⊞ 7.3K
Comment /ponytail to run again

Comments that could not be inline-anchored

pkg/cli/experiments_analyze_statistics.go:184

L184-194: yagni: computeExperimentAnalysisWithObservations only forwards to computeExperimentAnalysisWithObservationBundle with guardrailObservations=nil, and its only non-test caller was replaced by the Bundle variant. Delete the wrapper; call the Bundle version directly (nil guardrails) from tests.

pkg/cli/experiments_command.go:357

L357-367: yagni: computeExperimentAnalysesWithObservations only forwards to computeExperimentAnalysesWithObservationBundle wrapping graderObservationSets in a struct, with a single caller at line 354. Inline the Bundle call there and delete this middle layer.

@github-actions

Copy link
Copy Markdown
Contributor

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (1,142 new lines in pkg/) but does not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/54978-deterministic-experiment-decision-engine.md — review and complete it before merging.

🔒 This PR cannot merge until an ADR is linked in the PR body.

📋 What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff
  2. Complete the missing sections — add context the AI couldn't infer, refine the decision rationale, and list real alternatives you considered
  3. Commit the finalized ADR to docs/adr/ on your branch
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-54978: Deterministic Experiment Decision Engine

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

❓ Why ADRs Matter

"AI made me procrastinate on key design decisions. Because refactoring was cheap, I could always say 'I'll deal with this later.' Deferring decisions corroded my ability to think clearly."

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you.

📋 Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number (e.g., 54978-deterministic-experiment-decision-engine.md for PR #54978).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · sonnet46 · 124 AIC · ⌖ 28.8 AIC · ⊞ 9.5K · ◷
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deterministic decision engine is well-structured and the precedence logic aligns with the specification. The test coverage in experiments_decision_test.go is solid. Three issues worth addressing before merge:

  1. Asymmetric regression/improvement threshold comparison (blocking) — regression uses > while improvement uses >=, creating a subtle boundary inconsistency at the exact threshold value.
  2. Dead-code "missing" guardrail status (non-blocking) — the pendingGuardrailDecision checks for a status value that is never set, making the branch unreachable.
  3. Multi-variant vs below-min-samples precedence ordering (non-blocking) — a multi-variant experiment with low samples hits the EXTEND/insufficient_samples path before the INCONCLUSIVE/unsupported_multi_variant path, which may obscure the real reason.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 · 54.3 AIC · ⌖ 9.32 AIC · ⊞ 6.2K

}
if len(analysis.Variants) == 2 {
return analysis.Variants[0].Name, analysis.Variants[1].Name
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asymmetric threshold comparison for regression vs improvement

The regression check uses strict > while the improvement check uses >=:

regressionMaterial := normalized < 0 && -normalized > result.Policy.RegressionTolerance
improvementMaterial := normalized > 0 && normalized >= result.Policy.MinimumEffect

This asymmetry means a candidate that regresses by exactly regression_tolerance is not rejected, while a candidate that improves by exactly minimum_effect is promoted. Since regression_tolerance defaults to minimum_effect, this inconsistency may cause surprising behavior at the boundary. If the conservative-rejection design is intentional, please document it. Otherwise, consider making it >= for symmetry:

regressionMaterial := normalized < 0 && -normalized >= result.Policy.RegressionTolerance

@copilot please address this.

comparison := decisionComparison(analysis.Comparisons)
if comparison == nil {
return result.withDecision(experimentDecisionExtend, "insufficient_observations",
"primary metric observations are not available for statistical comparison")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead-code "missing" status in pendingGuardrailDecision

pendingGuardrailDecision checks for guardrail.Status == "missing", but this value is never set anywhere in the codebase — guardrails start at "unsupported" (set in newExperimentAnalysis) and transition to "insufficient_observations", "unsupported", "fail", or "pass". The "missing" branch is dead code.

Either remove it or add a path that sets "missing" (e.g., when a guardrail name references a metric with zero observations before the min-samples check).

@copilot please address this.

Comment thread pkg/cli/experiments_decision.go Outdated
return result.withDecision(experimentDecisionExtend, "insufficient_observations",
"at least two variants are required for a decision")
}
if hasVariantBelowMinimum(analysis.Variants) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-variant path reached before below-min-samples check

The current precedence is:

  1. len(variants) < 2 → EXTEND
  2. hasVariantBelowMinimum → EXTEND
  3. len(variants) != 2 → INCONCLUSIVE (multi-variant)

But a multi-variant experiment that also has variants below min_samples reaches step 2 first and returns EXTEND, while spec R-STAT-016 says INCONCLUSIVE should be returned for >2 variants. The current ordering causes the "insufficient_samples" EXTEND to shadow the unsupported multi-variant signal, which may hide the real issue for multi-variant experiments.

Consider checking len(variants) != 2 immediately after the < 2 check to make the multi-variant path explicit before applying single-variant readiness logic:

if len(analysis.Variants) < 2 { ... }
if len(analysis.Variants) != 2 { return inconclusive / unsupported_multi_variant }
if hasVariantBelowMinimum(...) { return extend }

@copilot please address this.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds deterministic experiment decisions to the analysis pipeline, including policy configuration, guardrail evaluation, and machine-readable output.

Changes:

  • Adds EXTEND, PROMOTE, REJECT, and INCONCLUSIVE decision logic.
  • Resolves grader/eval guardrails and exposes decision details through CLI/JSON.
  • Updates schemas, tests, and experiment documentation.
Show a summary per file
File Description
pkg/workflow/frontmatter_types.go Defines decision policy configuration.
pkg/workflow/compiler_experiments.go Extracts policies and validates guardrail references.
pkg/workflow/compiler_experiments_test.go Tests guardrail reference validation.
pkg/workflow/compiler_experiment_decision_test.go Tests decision policy extraction.
pkg/parser/schemas/main_workflow_schema.json Adds the decision configuration schema.
pkg/cli/experiments_grader_observations.go Resolves grader-backed guardrails.
pkg/cli/experiments_grader_observations_test.go Updates grader artifact-loading tests.
pkg/cli/experiments_eval_observations.go Resolves eval-backed guardrails.
pkg/cli/experiments_decision.go Implements deterministic decisions.
pkg/cli/experiments_decision_test.go Tests decisions and guardrail evaluation.
pkg/cli/experiments_command.go Wires guardrail observations into analysis.
pkg/cli/experiments_analyze_statistics.go Integrates decisions, output, and guardrail status.
pkg/cli/experiments_analyze_statistics_test.go Verifies decision JSON fields.
docs/src/content/docs/reference/frontmatter-full.md Regenerates frontmatter reference content.
docs/src/content/docs/experimental/experiments.md Documents configuration and decision output.
docs/src/content/docs/experimental/experiments-specification.md Specifies deterministic decision semantics.

Review details

  • Files reviewed: 17/17 changed files
  • Comments generated: 4
  • Review effort level: Balanced

Comment thread pkg/cli/experiments_decision.go Outdated
Comment on lines +70 to +77
if hasVariantBelowMinimum(analysis.Variants) {
return result.withDecision(experimentDecisionExtend, "insufficient_samples",
"one or more variants have fewer usable observations than min_samples")
}
if len(analysis.Variants) != 2 {
return result.withDecision(experimentDecisionInconclusive, "unsupported_multi_variant",
"automatic decisions currently require exactly two variants")
}
"enum": ["t_test", "mann_whitney", "proportion_test", "bayesian_ab"],
"description": "Statistical test to use for automated analysis by the reporting workflow. Valid values: t_test (Welch's two-sample t-test), mann_whitney (non-parametric rank test), proportion_test (two-proportion z-test), bayesian_ab (Bayesian A/B test)."
},
"decision": {
Comment on lines +385 to +387
Each entry in `analyses` includes `decision`, `reason_code`, `control`, `candidate`, `samples`,
`effect`, `evidence`, `decision_guardrails`, and `decision_policy`. Future promotion automation
can consume these fields without rerunning statistics or interpreting grader artifacts.
type GuardrailStatus struct {
Name string `json:"name"`
Threshold string `json:"threshold"`
Name string `json:"name"`

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /tdd and /codebase-design — requesting changes on test coverage gaps and a correctness issue.

📋 Key Themes & Highlights

Key Issues

  1. guardrailPassSummary returns &true for no guardrails — Guardrails.Passed is a non-nil true pointer when Configured == false, which is contradictory and could mislead downstream consumers (line 243 of experiments_decision.go).

  2. Asymmetric RegressionTolerance branch untested — every test passes nil for regressionTolerance, so the path where tolerance differs from minimum effect (line 262 of experiments_analyze_statistics.go) has no coverage.

  3. Unknown MetricDirection path untested — decisionEffect returns nil when direction is "", causing an EXTEND/analysis_unavailable result, but no test exercises this.

  4. Alpha expression is semantically fragile — *comparison.PValue <= 1-policy.Confidence is arithmetically correct but reads as if confidence and alpha are the same concept. A named alpha variable would make this self-documenting.

  5. ExperimentDecisionResult embedded in ExperimentAnalysis — promotes all decision fields to the top-level struct and JSON schema silently. Consider a named field or add a comment explaining the intentional flattening.

Positive Highlights

  • ✅ Pure, side-effect-free DecideExperiment function is an excellent design choice — easy to test and reason about.
  • ✅ Deterministic precedence ordering (samples → guardrails → evidence → effect) is clear and well-documented in the PR description.
  • ✅ Bayesian and frequentist paths handled in a single function with consistent output types.
  • ✅ Good table-driven test structure with descriptive names that read as specifications.
  • ✅ withDecision value-receiver helper avoids mutation bugs on the result.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 · 82.9 AIC · ⌖ 11.7 AIC · ⊞ 7.6K
Comment /matt to run again

}
if cfg.Decision != nil {
a.DecisionPolicy.MinimumEffect = cfg.Decision.MinimumEffect
a.DecisionPolicy.RegressionTolerance = cfg.Decision.MinimumEffect

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] RegressionTolerance silently defaults to MinimumEffect when regression_tolerance is omitted — the asymmetric-threshold branch has zero test coverage.

💡 Suggested tests

Add table rows to TestDecideExperiment exercising distinct minimumEffect vs regressionTolerance:

{
    name: "asymmetric tolerance: small regression below tolerance rejects",
    analysis: readyDecisionTestAnalysis("max", -0.015, &pValueStrong, nil, 0.05, floatPointer(0.01)),
    want: experimentDecisionReject, reasonCode: "candidate_regressed",
},
{
    name: "asymmetric tolerance: regression between tolerances is inconclusive",
    analysis: readyDecisionTestAnalysis("max", -0.03, &pValueStrong, nil, 0.05, floatPointer(0.05)),
    want: experimentDecisionInconclusive, reasonCode: "effect_below_threshold",
},

Without this, the branch at experiments_analyze_statistics.go:262–265 where RegressionTolerance diverges from MinimumEffect is never exercised by tests.

@copilot please address this.

if comparison.PValue == nil {
return nil
}
evidence.Significant = *comparison.PValue <= 1-policy.Confidence

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] Frequentist significance check uses 1-policy.Confidence which is correct numerically at runtime, but the semantics are fragile: alpha (the rejection threshold) equals 1 - confidence, so the expression is accidentally right. A reader — or future edit that changes confidence semantics — can easily break this silently.

💡 Suggested fix

Introduce a named local variable to make the intent explicit:

// alpha is the maximum acceptable false-positive rate (e.g. 0.05 at 95% confidence)
alpha := 1.0 - policy.Confidence
evidence.Significant = *comparison.PValue <= alpha

This is also consistent with how the Bayesian branch uses policy.Confidence directly (the conceptual mirror). The current form compiles and tests correctly today, but is a maintenance trap.

@copilot please address this.

}

func guardrailPassSummary(guardrails []GuardrailStatus) *bool {
if len(guardrails) == 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] guardrailPassSummary returns &true when guardrails is empty, meaning Guardrails.Passed is a non-nil true pointer even when no guardrails are configured at all. A downstream consumer checking result.Guardrails.Passed != nil && *result.Guardrails.Passed would incorrectly conclude all guardrails passed in experiments that have none.

💡 Suggested fix

Return nil for no-guardrail case, consistent with the Guardrails.Configured == false signal already present:

func guardrailPassSummary(guardrails []GuardrailStatus) *bool {
    if len(guardrails) == 0 {
        return nil  // no guardrails configured; leave Passed unset
    }
    // ...
}

The Configured field already communicates "no guardrails"; Passed == &true when Configured == false is contradictory.

@copilot please address this.

// ExperimentAnalysis holds statistical analysis results for one named A/B experiment.
// The analysis is computed from state.jsonl/state.json counts and optional experiment configuration.
type ExperimentAnalysis struct {
ExperimentDecisionResult

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] ExperimentDecisionResult is embedded (promoted) into ExperimentAnalysis. This means all 10+ decision fields (decision, reason, reason_code, control, candidate, samples, effect, evidence, decision_guardrails, decision_policy) are promoted to the top level of ExperimentAnalysis in Go, and they serialize as top-level JSON keys. The struct is already large; embedding adds invisible coupling — a future field added to ExperimentDecisionResult silently appears in ExperimentAnalysis JSON output.

💡 Consider using a named field instead
type ExperimentAnalysis struct {
    // ...existing fields...
    Decision ExperimentDecisionResult `json:"decision_result,omitempty"`
}

A named field makes the boundary explicit, keeps the JSON schema stable, and avoids unexpected promotions. Consumers access analysis.Decision.Decision instead of analysis.Decision, which is admittedly slightly verbose — but that's a good signal this struct is doing double duty.

If the promotion is intentional for JSON flattening, add a comment to ExperimentAnalysis explaining why.

@copilot please address this.

direction := analysis.MetricDirection
if direction != "min" && direction != "max" {
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] No test covers the direction == "" early-exit path in decisionEffect — when MetricDirection is neither "min" nor "max", the function returns nil, causing DecideExperiment to emit EXTEND with reason_code: "analysis_unavailable". This path is reachable for any experiment using a native metric without a resolved direction.

💡 Suggested test
{
    name: "unknown direction returns extend with analysis_unavailable",
    analysis: func() ExperimentAnalysis {
        a := readyDecisionTestAnalysis("max", 0.2, &pValueStrong, nil, 0.05, nil)
        a.MetricDirection = "" // simulate unresolved direction
        return a
    }(),
    want: experimentDecisionExtend, reasonCode: "analysis_unavailable",
},

@copilot please address this.

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

Copilot AI commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

@copilot run pr-finisher skill

Completed a pr-finisher pass and pushed the fixes in b185fab.

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot this PR still has outstanding review follow-up before maintainer investigation will be efficient.

  • Please address the remaining requested-changes feedback on deterministic decision and guardrail handling.
  • The open themes include guardrail summary correctness, boundary/threshold behavior, and ADR completion/linking in the PR body.
  • Please refresh the branch from base if GitHub offers an update path.
  • After updating the branch, run the pr-finisher skill and push the follow-up changes.

Recent blocking reviews:

  • github-actions requested changes at 2026-08-23T04:14:15Z
  • github-actions requested changes at 2026-08-23T04:17:32Z

Run: https://github.com/github/gh-aw/actions/runs/32620039467

Generated by 👨‍🍳 PR Sous Chef · gpt54 · 32.1 AIC · ⌖ 10 AIC · ⊞ 7.1K · ◷
Comment /souschef to run again

@pelikhan
pelikhan merged commit 38a76b3 into main Aug 23, 2026
33 of 34 checks passed
@pelikhan
pelikhan deleted the copilot/deterministic-experiment-decisions branch August 23, 2026 05:32
Copilot stopped work on behalf of gh-aw-bot due to an error August 23, 2026 05:33
Copilot AI requested a review from gh-aw-bot August 23, 2026 05:33
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.87.5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants