Skip to content

feat(sandcastle): add multi-critic ensemble with voted merge and parallel execution#1866

Open
jerome-benoit wants to merge 85 commits into
mainfrom
feat/sandcastle-multi-critic-ensemble
Open

feat(sandcastle): add multi-critic ensemble with voted merge and parallel execution#1866
jerome-benoit wants to merge 85 commits into
mainfrom
feat/sandcastle-multi-critic-ensemble

Conversation

@jerome-benoit

@jerome-benoit jerome-benoit commented May 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Add a multi-critic ensemble to .sandcastle/ with parallel critic execution, voted merge, optional arbiter synthesis, severity-aware DoS bounds, and backward-compatible N=1 behavior.

LoopStrategy additions

Field Type Default Purpose
criticPool readonly [AgentSpec, ...AgentSpec[]] AGENT_CRITIC_POOL_DEFAULT Non-empty critic catalog of { model, effort } pairs.
criticCount number max(AGENT_CRITIC_COUNT, criticPool.length) Critic slots per round, capped at MAX_CRITIC_COUNT = 8.
criticAgreementThreshold `number ((validCount: number) => number)` ⌈validCount × CRITIC_AGREEMENT_FRACTION⌉
criticFillStrategy `round-robin random-with-replacement` round-robin
criticEnsembleSeed number unset Required for deterministic random-with-replacement fill.
arbiter { agent?: AgentSpec; promptFile: string } unset Optional stage-2 arbiter over merged HIGH/CRITICAL findings.

Behavior

  1. Parallel critics — critic slots fan out with Promise.allSettled; each slot keeps one parse retry with retry nonce ${nonce}-r1.
  2. Quorum gate — merge runs only when validCount >= ceil(resolvedCriticCount / 2).
  3. Cross-critic dedup — key ${file}::${normalizeCategory(category)}::${ctxHash(line)}. Severity is intentionally absent so the same defect at different severities aggregates.
  4. Voted merge — severity and confidence aggregate by median tie-up. title / description / suggestion come from the lowest-slot voter. voters[] preserves original critic-slot indices even when middle slots parse-fail.
  5. Escape hatch — a below-threshold finding survives when at least one voter flags it with severity ∈ {HIGH, CRITICAL} and confidence = HIGH; contested = true is set and merged severity is clamped to [MEDIUM, HIGH]. Same-slot duplicate findings preserve escape-qualified evidence within a critic slot.
  6. Per-slot runaway cap — unilateral escape-hatch contributions from a slot are dropped wholesale when they exceed CRITIC_ESCAPE_CAP_PER_SLOT.
  7. Arbiter guard — parse failure, throw, or empty arbiter output preserves the merge result.
  8. Registry validation — misconfiguration fails at module load with StrategyValidationError before any sandbox is spawned.
  9. DoS hardeningFindingSchema enforces per-field bounds and parseFindings truncates each critic payload to MAX_FINDINGS_PER_CRITIC = 200 by severity-aware stable sort.

See .sandcastle/README.md for the full operational specification.

Files

  • Types & defaults.sandcastle/types.ts, .sandcastle/constants.ts
  • Pure merge module.sandcastle/merge-findings.ts
  • Loop kernel.sandcastle/refinement-loop.ts, .sandcastle/loop-control.ts
  • Parser.sandcastle/parse-findings.ts
  • Typed errors.sandcastle/errors.ts (SandcastleError extends Error with discriminant code)
  • Registry validation.sandcastle/strategies/index.ts
  • Default strategy.sandcastle/strategies/implement/strategy.ts
  • Documentation.sandcastle/README.md
  • Tests.sandcastle/tests/{merge-findings,refinement-loop,strategies,parse-findings,errors}.test.ts plus supporting kernel coverage

Backward compatibility

When no ensemble fields are set:

  • resolveCriticSlots returns slots from AGENT_CRITIC_POOL_DEFAULT.
  • The loop runs one critic slot with one parse retry.
  • Quorum is 1 / 1; the merge kernel is bypassed on the N=1 path.
  • The arbiter is skipped unless explicitly configured.

Quality gates

All quality gates at HEAD are green:

  • pnpm typecheck
  • pnpm lint
  • pnpm test:sandcastle
  • pnpm test
  • pnpm build

Out of scope

  • Specialized critic prompts per domain (security / perf / style)
  • Logprob-based aggregation
  • Dawid-Skene critic calibration
  • Telemetry sidecar persistence of perCriticOutputs
  • Task-discovery redesign in task-source.ts
  • Error-hierarchy unification across all sandcastle-specific error types

References

Load-bearing prior art:

Background reading:

AI-generated code disclosure

Per SAP CONTRIBUTING_USING_GENAI guideline, this PR was produced with substantial AI assistance.

Extend LoopStrategy with optional fields driving an N-critic ensemble
per refinement round:

  - criticCount, criticModels, criticEfforts (per-slot)
  - criticAgreementThreshold, criticFillStrategy, criticEnsembleSeed
  - criticArbiterModel/Effort/PromptFile (optional stage-2 arbiter)

Extend Finding with optional ensemble metadata (votes, voters,
disagreementScore, contested) populated by the merge function.
Extend RoundSnapshot with optional validCriticCount.

Add CriticSlot interface for resolved per-slot invocation.
Add canonical defaults: AGENT_CRITIC_COUNT=1, AGENT_CRITIC_MODELS,
MAX_CRITIC_COUNT=8, CRITIC_AGREEMENT_FRACTION=0.5,
CRITIC_FILL_STRATEGY_DEFAULT='round-robin'.

All fields are additive and optional; behavior is unchanged when no
ensemble fields are set on a strategy.

Refs: arXiv:2406.04692 (MoA), arXiv:2404.18796 (PoLL).
Pure module exposing the cross-critic deduplication and voted merge:

  - resolveCriticSlots: backward-compat single-slot for legacy strategies;
    round-robin or seeded random-with-replacement fill when criticCount
    exceeds criticModels.length.
  - findingDedupKey: file::normalizeCategory(category)::contextHash;
    severity intentionally absent so the same defect flagged at different
    severities aggregates into one merged finding.
  - normalizeCategory: lowercase + strip non-alphanumerics so phrasing
    variants ('sql-injection', 'SQL Injection', 'SQLInjection') vote
    together.
  - mergeCriticFindings: majority-vote with median-tie-up severity and
    confidence aggregation; singleton CRITICAL+HIGH-confidence escape
    hatch (cap at HIGH, contested=true) preserves rare findings without
    granting unilateral CRITICAL veto power.
  - disagreementScore: variance of severity ranks normalized by 9/4 of
    the 4-level ordinal ladder, in [0, 1]; calibration-free uncertainty
    signal preferred over LLM textual confidence labels.

Title/description/suggestion are picked from the lowest-slot voter
(deterministic, bias-free) rather than the longest-description voter,
which would amplify the verbosity bias documented in arXiv:2306.05685
across all LLM judges including GPT-4.

Tests cover backward compat (N=1 identity), round-robin fill, seeded
random fill reproducibility, severity median tie-up, cross-critic
dedup with category-phrasing variance, singleton-CRITICAL escape with
HIGH cap, disagreement scoring, and ordering invariants.

Refs: arXiv:2306.05685, arXiv:2207.05221, arXiv:2406.04692.
Refactor the critic phase to fan out N slots in parallel via
Promise.allSettled with per-slot single parse-retry (mirroring legacy
single-critic semantics), enforce a simple-majority quorum, then call
mergeCriticFindings with precomputed line-context hashes.

Quorum: when fewer than ceil(N/2) slots return parseable findings the
round is marked critic_errored. The threshold is calibrated for crash
faults; LLM critics fail by timeout/parse failure/OOM, not adversarial
collusion, so BFT thresholds (Castro & Liskov, OSDI 1999) are not
warranted.

Per-slot nonces keep stdout streams unambiguous. Each critic runs with
its own model+effort resolved from the slot list. The signal is
propagated to all slots so cooperative cancellation kills every
in-flight critic.

Optional stage-2 arbiter: when criticArbiterModel and
criticArbiterPromptFile are both set, after merge the arbiter receives
the per-critic outputs and the merged list and emits a refined list.
Triggered only when the merged list contains at least one HIGH or
CRITICAL finding to bound cost. Failure is non-fatal: returns the
merge result unchanged on parse failure or sandbox error.

Backward compat: when no ensemble fields are set, resolveCriticSlots
returns a single legacy slot; the loop runs one sandbox.run with one
parse-retry (byte-equivalent to the prior single-critic path).

Carries validCriticCount upward into RoundSnapshot for observability.

Refs: arXiv:2406.04692 (MoA stage-2 aggregator pattern).
Extend STRATEGY_REGISTRY indexing with fail-fast validation of every
optional ensemble field, mirroring the existing STRATEGY_KEY_PATTERN
and CONTROL_TAG_PATTERN guards: misconfiguration throws a field-named
error at module load before any sandbox is spawned.

Rules:
  - criticCount must be an integer in [1, MAX_CRITIC_COUNT].
  - criticModels (when set) must be a non-empty array of non-blank
    strings.
  - criticEfforts (when array) length must equal criticModels.length.
  - criticAgreementThreshold (when number) must be an integer in
    [1, criticCount].
  - criticFillStrategy='random-with-replacement' requires a numeric
    criticEnsembleSeed for reproducibility.
  - criticArbiterModel and criticArbiterPromptFile must be set
    together.

Expose validateLoopStrategyEnsemble for unit tests with a
caller-supplied error context label.

Tests cover: 17 cases including the legacy-single-critic accept path,
each rejection path, scalar criticEfforts broadcast, both arbiter
fields set together, and seed-required random fill.
Add README covering the orchestrator module map and the multi-critic
ensemble: strategy fields, slot resolution, parallel execution model,
quorum policy, merge algorithm (dedup key, median tie-up severity,
singleton-CRITICAL escape, disagreementScore), convergence and
quality-ratchet semantics, registry-load validation rules, cost
model, and SOTA references.

Documents the deliberate divergence from industry SAST severity
aggregation (CVSS v3.1 mandates MAX, DefectDojo preserves first-seen):
median tie-up is more robust to outlier LLM critics, and the
singleton-CRITICAL escape hatch covers asymmetric-cost cases without
granting unilateral CRITICAL veto power to any single critic.
Copilot AI review requested due to automatic review settings May 20, 2026 18:04
@hyperspace-insights

Copy link
Copy Markdown
Contributor

Summary

The following content is AI-generated and provides a summary of the pull request:


Add Multi-Critic Ensemble with Voted Merge and Parallel Execution

New Feature

✨ Extends the .sandcastle/ actor↔critic refinement loop from a single critic per round to an N-critic ensemble with parallel execution, majority-vote deduplication, and ensemble disagreement signalling. Fully backward-compatible: when no ensemble fields are configured on a strategy, the loop runs the legacy single-critic path with byte-equivalent behavior.

The design addresses the single-point-of-failure in code review quality by running up to 8 heterogeneous critic models simultaneously and aggregating their findings via ordinal severity aggregation, cross-critic deduplication, and quorum gating — drawing on LLM ensemble research (PoLL, MoA, Self-Consistency).

Changes

  • .sandcastle/README.md (new): Comprehensive documentation covering module map, ensemble semantics (slot resolution, parallel execution, quorum, merge algorithm, singleton CRITICAL escape, disagreement scoring), validation rules, cost model, and literature references.
  • .sandcastle/constants.ts: Added ensemble constants: AGENT_CRITIC_COUNT, AGENT_CRITIC_MODELS, CRITIC_AGREEMENT_FRACTION, CRITIC_FILL_STRATEGY_DEFAULT, and MAX_CRITIC_COUNT = 8.
  • .sandcastle/merge-findings.ts (new): Pure, deterministic merge module implementing mergeCriticFindings (voted dedup with median tie-up severity, singleton CRITICAL escape, disagreement scoring), resolveCriticSlots (round-robin and seeded random-with-replacement fill strategies), findingDedupKey, and normalizeCategory.
  • .sandcastle/refinement-loop.ts: Refactored the critic phase into parallel Promise.allSettled fan-out over N slots (runCritic), per-slot retry logic (runOneCritic), quorum enforcement (⌈N/2⌉), and an optional stage-2 MoA-pattern arbiter pass (maybeRunArbiter). Legacy single-critic behavior preserved when only one slot is resolved.
  • .sandcastle/strategies/index.ts: Added validateLoopStrategyEnsemble (exported for tests) and validateEnsembleFields (called at registry load) to fail fast on misconfigured ensemble fields before any sandbox is spawned.
  • .sandcastle/types.ts: Added CriticSlot interface; extended Finding with optional votes, voters, disagreementScore, and contested fields; added all new ensemble fields to LoopStrategy; added validCriticCount to RoundSnapshot.
  • tests/sandcastle/merge-findings.test.ts (new): 24 unit tests covering backward compat (N=1 identity), slot resolution, severity median tie-up, category-phrasing dedup, singleton CRITICAL escape with HIGH cap, disagreement scoring, and output ordering.
  • tests/sandcastle/strategies-validation.test.ts (new): 17 unit tests verifying all validation rules (invalid counts, empty models, effort length mismatch, threshold bounds, unseeded random fill, half-set arbiter).

  • 🔄 Regenerate and Update Summary
  • ✏️ Insert as PR Description (deletes this comment)
  • 🗑️ Delete comment
PR Bot Information

Version: 1.20.51

  • Event Trigger: pull_request.opened
  • Output Template: Default Template
  • Correlation ID: 1abf1399-ffb5-44e1-a760-c7562d5faf13
  • Summary Prompt: Default Prompt
  • File Content Strategy: Full file content
  • LLM: anthropic--claude-4.6-sonnet

@hyperspace-insights hyperspace-insights 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.

Summary

The PR introduces a well-structured multi-critic ensemble with parallel execution, majority voting, and deterministic merge semantics. The core design is sound and backward-compatible. Several substantive issues were identified:

  1. Floating-point precision loss in deterministicIndex — combining two UInt32 values via high * 0x100000000 + low exceeds Number.MAX_SAFE_INTEGER, making the modulo result unreliable and breaking the reproducibility guarantee. Should use BigInt.

  2. criticEfforts validation uses wrong upper bound in validateLoopStrategyEnsemble — when criticCount is set without criticModels, the expected length falls back to 1 instead of criticCount, causing valid configurations to be wrongly rejected and invalid ones accepted.

  3. Per-slot effort array mismatch in resolveCriticSlotsresolvePerSlotEfforts is called with count (total fill-extended slots) but is validated against criticModels.length, so fill-extension slots silently lose their declared per-model effort and fall back to the scalar criticEffort.

  4. Docstring incorrectly says "sample variance" for severityDisagreementScore — the implementation correctly uses population variance; the mislabeling could cause confusion when the formula is compared to textbook references.

  5. Missing abort guard before retry in runOneCritic — if the abort signal fires after a parse failure but before the retry sandbox.run, the slot wastes a full idle timeout before the parent runCritic can propagate the abort.

PR Bot Information

Version: 1.20.51

Comment thread .sandcastle/merge-findings.ts
Comment thread .sandcastle/merge-findings.ts Outdated
Comment thread .sandcastle/strategies/index.ts Outdated
Comment thread .sandcastle/merge-findings.ts Outdated
Comment thread .sandcastle/merge-findings.ts Outdated
Comment thread .sandcastle/merge-findings.ts Outdated
Comment thread .sandcastle/strategies/index.ts Outdated
Comment thread .sandcastle/refinement-loop.ts Outdated

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

Extends the .sandcastle/ actor↔critic refinement loop to support a multi-critic ensemble per round, running critics in parallel and merging their findings via deterministic voting + deduplication, with optional stage-2 arbiter refinement and strategy-level fail-fast validation.

Changes:

  • Added ensemble configuration fields to strategy/types and canonical defaults (count/models/efforts/quorum/arbiter).
  • Implemented ensemble merge logic (resolveCriticSlots, dedup keying, majority voting, severity/confidence aggregation, disagreement scoring).
  • Refactored the refinement loop to run critics in parallel, enforce quorum, merge outputs, and (optionally) run an arbiter; added documentation and unit tests.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/sandcastle/strategies-validation.test.ts Unit tests for ensemble-field validation errors and backward compatibility.
tests/sandcastle/merge-findings.test.ts Unit tests for slot resolution and deterministic merge/vote behavior.
.sandcastle/types.ts Adds ensemble strategy fields and new optional merged-finding metadata fields.
.sandcastle/constants.ts Introduces ensemble-related canonical defaults and limits (e.g., MAX_CRITIC_COUNT).
.sandcastle/merge-findings.ts New pure module implementing slot resolution and voted merge/dedup/aggregation.
.sandcastle/refinement-loop.ts Runs multiple critics in parallel, merges results, and optionally invokes an arbiter.
.sandcastle/strategies/index.ts Adds registry-load validation for ensemble configuration + exports validator for tests.
.sandcastle/README.md Documents module map and ensemble semantics/validation/cost model.
Comments suppressed due to low confidence (1)

.sandcastle/refinement-loop.ts:792

  • runOneCritic derives nonce as ${baseNonce}-c${slot.index}, but parseFindings rejects any nonce that isn’t strictly hex (/^[0-9a-f]+$/). This makes parsing fail for every slot (including the retry), so all critics will return null and quorum will typically fail. Use a hex-only per-slot nonce (e.g., derive a hex digest from baseNonce+slotIndex) or change parseFindings to accept/escape the nonce format safely.
 * @param baseNonce - Round-level nonce; per-slot nonce is `${baseNonce}-c{slot.index}`.
 * @param slot - Resolved critic slot (model + effort + index).
 * @returns Parsed findings, or null on definitive parse failure.
 */
async function runOneCritic (
  ctx: LoopContext,
  round: number,
  baseNonce: string,
  slot: CriticSlot
): Promise<Finding[] | null> {
  const { baseBranch, sandbox, signal, spec, strategy } = ctx
  const nonce = `${baseNonce}-c${String(slot.index)}`

  let critic = await sandbox.run({
    agent: agentProvider(slot.model, slot.effort),
    completionSignal: COMPLETION_SIGNAL,
    idleTimeoutSeconds: AGENT_IDLE_TIMEOUT_S,
    maxIterations: 1,
    name: `Critic #${spec.id} R${String(round)} C${String(slot.index)}`,
    promptArgs: { ...strategy.buildCriticArgs(spec, baseBranch), NONCE: nonce },
    promptFile: strategy.criticPromptFile,
    signal,
  })

Comment thread .sandcastle/refinement-loop.ts Outdated
Comment thread .sandcastle/merge-findings.ts Outdated
Comment thread .sandcastle/strategies/index.ts
…el field

The introduction of AGENT_CRITIC_MODELS (plural) and the criticModels
field in this change makes the singular AGENT_CRITIC_MODEL constant
and the criticModel strategy field redundant. Per the codebase's
single-source-of-truth and naming-coherence principles (AGENTS.md),
retain only the plural names.

  - Drop the singular AGENT_CRITIC_MODEL constant; declare
    AGENT_CRITIC_MODELS as a tuple literal (`as const`) so
    AGENT_CRITIC_MODELS[0] is statically guaranteed non-undefined.
  - Drop the LoopStrategy.criticModel field; strategies that need a
    single critic now declare criticModels: ['model-id'].
  - Simplify resolveCriticSlots: the previous triple-fallback chain
    collapses to a binary check now that AGENT_CRITIC_MODELS is
    non-empty by construction.
  - Update tests and docs accordingly; the 'legacy fallback' test
    becomes 'default slot from constants' covering the same
    !ensembleConfigured branch via AGENT_CRITIC_MODELS[0].

No production strategy in STRATEGY_REGISTRY uses the legacy criticModel
field, so this is a no-op on production behavior. Net diff: -9 lines.
…critic pool, and arbiter

Replace parallel actorModel/actorEffort and criticModels/criticEfforts
fields with a single canonical AgentSpec value type that bundles model
and reasoning effort atomically. Effort is REQUIRED on AgentSpec: the
right effort is a property of the model, so silent role-wide effort
fallbacks would defeat the purpose of the pairing.

Field shape:
  - actor:        AgentSpec                       (single, optional)
  - criticPool:   readonly [AgentSpec, ...AgentSpec[]]  (non-empty tuple)
  - arbiter:      { agent: AgentSpec; promptFile: string }  (struct, optional)

Compile-time guarantees from the new type shape eliminate three classes
of runtime failures present in the previous design:

  1. Parallel-array desync: reordering criticModels no longer silently
     misaligns criticEfforts because each pool entry carries its own
     effort. Structurally impossible.
  2. Empty critic pool: encoded by the non-empty tuple type. Runtime
     length-zero check redundant.
  3. Arbiter half-set: encoded by the struct. The XOR 'set together'
     cross-field validation rule is gone.

Defaults migrate from scalar AGENT_ACTOR_MODEL/EFFORT and
AGENT_CRITIC_MODELS/EFFORT to AGENT_ACTOR_DEFAULT and
AGENT_CRITIC_POOL_DEFAULT, both of AgentSpec shape, restoring single-
source-of-truth per AGENTS.md DRY principle.

Validation rules collapsed: 8 ad-hoc field checks (count range, models
empty, efforts length match, efforts elements, arbiter XOR, etc.) into
5 structural checks (delegating to validateAgentSpec for any role).

BREAKING CHANGE: LoopStrategy fields actorModel, actorEffort,
criticModels, criticEfforts, criticEffort, criticArbiterModel,
criticArbiterEffort, criticArbiterPromptFile are removed. Use the new
AgentSpec-based fields instead. Internal-only module: the sole
production strategy (implementStrategy) relies on defaults and is
unaffected.

Refs: AGENTS.md DRY / single-source-of-truth / naming-coherence.
Copilot AI review requested due to automatic review settings May 20, 2026 18:37

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

Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.

Comments suppressed due to low confidence (2)

.sandcastle/refinement-loop.ts:569

  • The perCriticOutputs param doc says "null entries excluded", but runCritic passes the raw perCriticOutputs array including nulls (and you stringify it as-is). Either filter out nulls before passing to the arbiter, or update the docstring/arbiter prompt expectations accordingly.
 * @param perCriticOutputs - Raw per-slot findings (null entries excluded).
 * @param merged - Merged finding list from `mergeCriticFindings`.

.sandcastle/merge-findings.ts:207

  • resolveCriticSlots defaults count to max(AGENT_CRITIC_COUNT, pool.length), which means setting a multi-entry criticPool without also setting criticCount will run all pool entries by default. This conflicts with the docs/PR narrative that criticCount defaults to 1, and it can be a surprising cost increase. Consider defaulting count strictly to AGENT_CRITIC_COUNT (1) unless criticCount is explicitly set, or update the documentation to clearly state the pool-length-driven default.
  const pool = strategy.criticPool ?? AGENT_CRITIC_POOL_DEFAULT
  const count = strategy.criticCount ?? Math.max(AGENT_CRITIC_COUNT, pool.length)
  const fillStrategy = strategy.criticFillStrategy ?? CRITIC_FILL_STRATEGY_DEFAULT

Comment thread .sandcastle/refinement-loop.ts Outdated
Comment thread .sandcastle/refinement-loop.ts Outdated
Comment thread .sandcastle/refinement-loop.ts Outdated
Comment thread .sandcastle/merge-findings.ts Outdated
Comment thread .sandcastle/merge-findings.ts Outdated
Comment thread .sandcastle/README.md Outdated
Comment thread .sandcastle/strategies/index.ts Outdated
Comment thread .sandcastle/strategies/index.ts Outdated
Complete the AgentSpec unification across all four agent roles. Two
roles were still on the legacy split-scalar pattern after the previous
refactor:

  - Planner (used in task-source.ts for issue triage) was wired to
    AGENT_PLANNER_MODEL + AGENT_PLANNER_EFFORT scalars.
  - Arbiter had no AgentSpec default at all; opt-in strategies had to
    declare the agent inline.

Both now derive from canonical AgentSpec constants:

  - AGENT_PLANNER_DEFAULT: AgentSpec
  - AGENT_ARBITER_DEFAULT: AgentSpec

task-source.ts uses AGENT_PLANNER_DEFAULT.model / .effort. Strategies
opting into the arbiter spread AGENT_ARBITER_DEFAULT into arbiter.agent
to inherit the canonical default while keeping the struct's
both-fields-required-when-set type guarantee.

Result: every agent invocation in .sandcastle/ now sources its (model,
effort) pair from the same atomic AgentSpec shape, restoring the
single-source-of-truth principle uniformly. The legacy
AGENT_PLANNER_MODEL and AGENT_PLANNER_EFFORT scalars are removed; no
external consumers reference them.
…ostic architecture

The previous README was multi-critic-ensemble-focused and didn't make
clear that .sandcastle/ is a generic actor↔critic refinement framework
whose kernel knows nothing about issue-to-PR (or any task type).
Strategies are pluggable; only the registered `implement` strategy
binds the kernel to GitHub issue resolution today.

Restructured per state-of-the-art conventions for agent-loop
documentation (LangGraph capability-first lead, SWE-agent one-action
quickstart, CrewAI 'using with AI coding agents' meta-section).

New sections, in order:

  - What it is — kernel + strategies, ephemeral sandbox.
  - How it works — ASCII flow diagram (filling a gap none of the four
    surveyed reference projects address in their READMEs).
  - Lifecycle (per nightly run) — discovery → fan-out → loop → finalize,
    8 numbered sub-steps for the loop body.
  - Module map — per-file purpose with explicit 'strategy-aware?'
    column making the kernel/strategy boundary visible.
  - Core concepts — the 8 vocabulary terms used throughout the module.
  - Adding a new strategy — 4 concrete steps; emphasizes the kernel
    is unchanged.
  - Multi-critic ensemble — condensed; preserves SOTA-divergence note.
  - Robustness mechanisms — every defensive code path (ratchet, best-
    state, parse retry, quorum, mid/post-loop validation, push rescue,
    sandbox abort) with file:function references.
  - Configuration — agent defaults, loop tunables, env vars in three
    grouped tables.
  - Sandboxing — Docker lifecycle, hooks, pnpm-store mount.
  - Running — pnpm sandcastle + CI cron schedule.
  - Testing — how to run the 41 unit tests.
  - Using with AI coding agents — extension contract for Copilot/
    Claude reading this README.
  - References — primary sources cited inline in code.

All documented constant values, file paths, command strings, and test
counts cross-checked against the source. Length: 214 lines.
Copilot AI review requested due to automatic review settings May 20, 2026 19:00

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

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

.sandcastle/refinement-loop.ts:569

  • The maybeRunArbiter docstring still refers to criticArbiterModel/criticArbiterPromptFile, but the code gates on strategy.arbiter. Also, perCriticOutputs is documented as having null entries excluded, but the function serializes the array including nulls (JSON.stringify(perCriticOutputs, ...)). Update the comment (or filter nulls before passing) so the documentation matches the actual behavior and current field names.
/**
 * Optional stage-2 arbiter pass (MoA pattern, arXiv:2406.04692). Triggered
 * only when `criticArbiterModel`/`criticArbiterPromptFile` are both set AND
 * the merged list contains at least one HIGH or CRITICAL finding. Failure
 * is non-fatal: returns the unrefined merged list on any error.
 * @param ctx - Loop context.
 * @param round - Current round number.
 * @param baseNonce - Round-level nonce; arbiter nonce is `${baseNonce}-arbiter`.
 * @param perCriticOutputs - Raw per-slot findings (null entries excluded).
 * @param merged - Merged finding list from `mergeCriticFindings`.

Comment thread .sandcastle/merge-findings.ts Outdated
Comment thread .sandcastle/strategies/index.ts
Comment thread .sandcastle/strategies/index.ts Outdated
Comment thread .sandcastle/refinement-loop.ts Outdated
…dedicated scripts

The sandcastle orchestrator's tests were nested at tests/sandcastle/,
matched by the repo-wide `pnpm test` glob, and not invocable
independently. They now live next to the module they exercise, under
.sandcastle/tests/, and are run by a dedicated `pnpm test:sandcastle`
script — physically and logically untangled from the main app test
suite.

Module is now self-contained at the test layer too: production code
(.sandcastle/*.ts) and tests (.sandcastle/tests/*.test.ts) sit
together, with the same self-imposed boundary as before — zero
imports from src/, ui/, or tests/helpers/.

Changes:

  - Move tests/sandcastle/{merge-findings,strategies-validation}.test.ts
    to .sandcastle/tests/ and adjust relative imports (../../.sandcastle/
    becomes ../).
  - Add three scripts mirroring the app pattern:
    test:sandcastle, test:sandcastle:debug, test:sandcastle:coverage.
    Coverage targets .sandcastle/**/*.ts and writes lcov to
    .sandcastle/coverage/lcov.info.
  - Add coverage/ to .sandcastle/.gitignore.
  - Add the test:sandcastle and test:sandcastle:coverage steps to the
    Build simulator job in .github/workflows/ci.yml (the root-level
    job; the three sub-project jobs use their own working-directory
    package.json and are unaffected).
  - Update .sandcastle/README.md Testing section with the new commands
    and a note about the deliberate separation.

Test count conservation: `pnpm test` drops from 2448 to 2407, and
`pnpm test:sandcastle` runs the missing 41. No test removed, no test
duplicated. tsconfig.json, eslint scope, and prettier scope already
cover .sandcastle/ — no tooling changes required.
…age, properties)

Apply the 12 consensus recommendations from the cross-validated test
audit (3 oracles + cross-validation). Suite grows from 41 tests
covering only `merge-findings.ts` and `validateLoopStrategyEnsemble`
to 95 tests covering kernel pure helpers, infrastructure primitives,
finalization arg-builder, registry validators, partial-recovery JSON
parser, and property-based invariants on the most algorithmically
intricate module.

Production extractions (kept signatures; minimal-diff refactors):

  - parseFindings → .sandcastle/parse-findings.ts (was file-private in
    refinement-loop.ts; pure regex+JSON, security-relevant nonce guard).
  - checkEarlyExit, shouldResetToBest, buildRoundSnapshot,
    resolveLoopOptions → .sandcastle/loop-control.ts (was
    file-private; pure decision logic).
  - isValidSha(s) → .sandcastle/utils.ts (replaces 3 inline
    /^[0-9a-f]{40}$/ regex sites in refinement-loop.ts).
  - StrategyValidationError extends Error { readonly field: string }
    exported from strategies/index.ts; validators migrated to throw it.
  - validateRegistryEntries(entries) exported from strategies/index.ts;
    factored out of indexByKey to enable direct testing of the four
    fail-fast rules (key pattern, controlTag pattern, duplicate keys,
    prefix-overlap).

New test files (under .sandcastle/tests/):

  - factories.ts            — shared fixtures (spec, fakeFinding,
                              fakeStrategy, baseStrategy, ctxHashesFor,
                              asInvalidPool, fakeSpec, fakeLoopResult).
  - utils.test.ts           — 3 tests for isValidSha.
  - parse-findings.test.ts  — 5 tests for nonce-tagged JSON extraction.
  - loop-control.test.ts    — 14 tests across the 4 extracted helpers.
  - concurrency-pool.test.ts — 7 tests (RangeError, FIFO, release-on-
                              reject, peak-bound under burst).
  - finalizer.test.ts       — 8 tests for buildPrArgs.
  - types.test.ts           — 3 tests for parseFindingsSafe partial recovery.
  - merge-findings.property.test.ts — 5 fast-check property tests.

Renamed and rewritten:

  - strategies-validation.test.ts → strategies.test.ts (consolidates
    validateLoopStrategyEnsemble + validateRegistryEntries under one
    describe('strategies'); migrates ALL assert.throws(/regex/) to
    assert.throws(err => err instanceof StrategyValidationError &&
    err.field === '...') per AGENTS.md typed-error convention).

Test data robustness:

  - 6 tests in merge-findings.test.ts now set severity / confidence
    explicitly where dedup behavior was implicitly coupled to
    fakeFinding factory defaults (audit recommendation).

Coverage (sandcastle): all targeted modules at 100% lines / 100% funcs.
Aggregate: 96.91% lines, 94.01% branches, 94.91% funcs.

refinement-loop.ts and task-source.ts orchestration code remain
outside unit-test coverage (3/3 audit anti-recommendation: validate
end-to-end via the existing nightly Docker run, not via mocked
Sandbox, which would couple tests to @ai-hero/sandcastle internals).

Dependencies: added fast-check ^4.x as devDep.
Tests: 41 → 95 (+54). Build clean. Typecheck clean. Lint 0 errors.
Copilot AI review requested due to automatic review settings May 20, 2026 19:59

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

Copilot reviewed 23 out of 24 changed files in this pull request and generated 4 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)

.sandcastle/refinement-loop.ts:664

  • Per-slot nonce is ${baseNonce}-c${slot.index}, but parseFindings only accepts lowercase-hex nonces. This causes every critic parse to fail (both attempts), which will cascade into quorum failures and effectively disable the critic phase. Derive per-slot nonces using hex-only characters (e.g., append a hex digit/prefix) or adjust parseFindings to safely handle non-hex nonces.
  const { baseBranch, sandbox, signal, spec, strategy } = ctx
  const nonce = `${baseNonce}-c${String(slot.index)}`

Comment thread .sandcastle/refinement-loop.ts Outdated
Comment thread .sandcastle/refinement-loop.ts Outdated
Comment thread .sandcastle/refinement-loop.ts Outdated
Comment thread .sandcastle/merge-findings.ts Outdated
…edup-key parity)

Apply the cross-validated remediations from the three-oracle PR audit on PR
#1866. The headline fix (P0) restores the multi-critic ensemble to functional
state — the runtime per-slot and arbiter nonces (`<base>-c<idx>`,
`<base>-arbiter`) were rejected by `parseFindings`'s strict pure-hex regex,
making every critic and arbiter invocation return null in production. The
remaining changes fix four latent correctness bugs and harden the type model.

P0 — release blocker (consensus 2/3 oracles):

  - `parse-findings.ts` — nonce alphabet relaxed from `/^[0-9a-f]+$/` to
    `/^[0-9a-z][0-9a-z-]{0,63}$/`. Lowercase letters and hyphens are not
    regex metacharacters when interpolated outside a character class, so
    the constructed tag regex stays injection-safe; bounded length defeats
    catastrophic backtracking. Existing test which asserted hyphens are
    rejected was contradicting runtime; rewritten as alphabet-violation
    test covering uppercase, dot, regex meta, leading hyphen, length cap.
    Two new regression tests lock the runtime shapes `cafe1234-c0` and
    `cafe1234-arbiter`.

P0 — missing safety net (consensus 3/3 oracles):

  - `tests/refinement-loop.test.ts` (new) — kernel integration tests with
    a `SandboxInstance` stub. 11 cases cover `runOneCritic` (parse OK,
    fresh-suffix retry, both-fail null), `runCritic` (quorum OK with
    merged votes, quorum fail, single-slot legacy identity), and
    `maybeRunArbiter` (HIGH/CRITICAL trigger, default-agent inheritance,
    LOW/MED skip, parse-fail merged passthrough, throw merged
    passthrough, arbiter-undefined skip). `runOneCritic`, `runCritic`,
    `maybeRunArbiter` exported with "Exported for kernel integration
    tests; not part of the public API" annotation.

P1 — correctness fixes:

  - `refinement-loop.ts` (B1) — `bestSha` tracker switched from
    `newFindings.length` (post-dedup against `seenKeys`, monotone-shrinking
    metric biased toward late rounds) to `nonLowFindings.length` (absolute
    quality, matches the README contract "track the SHA at the round with
    fewest non-LOW findings").

  - `refinement-loop.ts` (B2) — `computeFindingKey` now applies
    `normalizeCategory` to its category component so cross-round dedup
    uses the same key space as `findingDedupKey` (cross-critic merge).
    Without this, a finding flagged at round N as `"sql-injection"` and
    re-emitted at round N+1 as `"SQLInjection"` would survive dedup and
    drive spurious quality-ratchet rollbacks. `normalizeCategory` exported
    from `merge-findings.ts` so both sites consume one implementation.

  - `refinement-loop.ts` (B3) — `AGENT_ARBITER_DEFAULT` wired as the
    canonical default for arbiter agents. Strategies now declare
    `arbiter: { promptFile: '...' }` and the kernel resolves the agent as
    `strategy.arbiter.agent ?? AGENT_ARBITER_DEFAULT`. Type
    `LoopStrategy.arbiter.agent` is now optional. The constant is no
    longer dead code; the README spread-default claim is now accurate.

  - `refinement-loop.ts` (B4) — `deduplicateFindings` rewritten zip-based
    over the parallel `keys` array. Drops the O(n²) `findings.indexOf(f)`
    look-up and the reference-equality footgun (`indexOf` returns the
    first match, which would mis-key reference-equal duplicates).

  - `refinement-loop.ts` + `types.ts` (B5) — `AbortSignal` now propagated
    through `LoopStrategy.validate`. Signature widened to
    `(cwd, spec, signal?) => Promise<boolean>`; the kernel default
    closure forwards `signal` from outer scope; all three call sites
    pass the signal so strategy overrides honor abort. Without this, the
    15-min `VALIDATION_TIMEOUT_MS` could outlive a task abort.

  - `strategies/index.ts` (B6) — `validateLoopStrategyEnsemble` now
    rejects `criticPool.length > MAX_CRITIC_COUNT` (was previously only
    enforced on `criticCount`). Closes the documented contract gap where
    a strategy could ship a 9-entry pool and bypass the cost ceiling.
    Test added.

  - `types.ts` (B7) — `RoundSnapshot.validCriticCount` JSDoc corrected.
    The field is telemetry only; the previous claim that it is consulted
    by the quality ratchet was documentation drift (the ratchet uses
    absolute non-LOW finding counts).

P2 — type & docs hygiene:

  - `README.md` — qualitative description of test coverage replaces the
    stale "41 unit tests" count (suite is now 111).
  - `refinement-loop.ts` — `maybeRunArbiter` JSDoc refreshed: stale
    `criticArbiterModel`/`criticArbiterPromptFile` reference replaced
    with `strategy.arbiter` (the post-unification field).
  - `types.ts` + `refinement-loop.ts` — `LoopResult.failureReason` typed
    as the string-literal union `'actor_error' | 'critic_parse_failed' |
    'quality_regression'` (was `string`).
  - `refinement-loop.ts` — `runOneCritic` retry now uses a fresh `-r1`
    suffix on the second attempt's nonce so the agent sees a different
    tag envelope; locked by integration test #2.
  - `merge-findings.ts` — `severityDisagreementScore` JSDoc corrected
    (population variance, not sample; `Math.min(1, …)` clamp explained).

P3 — cosmetic:

  - `merge-findings.ts` — `contested` now via conditional spread
    (`...(!aboveThreshold && { contested: true })`) instead of
    unconditional `contested: false` on every above-threshold finding.
  - `README.md` — module map adds `loop-control.ts` and `parse-findings.ts`.

Tests: 95 → 111 (+16). All quality gates green: `pnpm typecheck`, `pnpm
lint` (0 errors, 4 cspell baseline warnings), `pnpm test:sandcastle`
(111/111), `pnpm test` (2401/2401, 6 pre-existing skips), `pnpm build`.
…finalize-signal)

Apply the cross-validated round-2 audit consensus on PR #1866. The
previous remediation (commit a0a2f34) closed all round-1 findings; this
commit closes the gaps the round-2 oracles surfaced — one P0 correctness
bug single-flagged at HIGH (the line-less-findings dedup-key parity gap
that escaped the round-1 'normalizeCategory' fix), six 2/3-consensus
P1 issues, and three 2/3-consensus P2 cleanups.

P0 — single-oracle HIGH (correctness):

  - SO1 — `computeFindingKey` no-line branch produced a third key segment
    derived from a normalized title hash, while `findingDedupKey` for the
    same line-less finding produced a third segment derived from
    `fallbackHash(file, '_')`. Cross-round and cross-critic dedup
    therefore disagreed on every finding without `line`. Round-1's C16
    fix only aligned the second segment (`category` via
    `normalizeCategory`); the third segment is now also aligned by
    extracting `noLineFallbackHash(file)` in `merge-findings.ts` and
    having both consumers call it. New tests assert key parity on
    line-less findings (`tests/merge-findings.test.ts`).

P1 — consensus 2/3 oracles:

  - CV1 / B1 — `runRefinementLoop` end-to-end coverage. New
    `describe('runRefinementLoop end-to-end')` block in
    `tests/refinement-loop.test.ts` uses a real temp git repo (no mocked
    `execFileAsync`) plus a `SandboxInstance` stub that materializes
    `git commit --allow-empty` for actor calls and tagged `<findings>`
    stdout for critic calls. First case locks the post-loop retry
    success path: validate fails for 3 main rounds → status='exhausted',
    post-loop validate passes → status='converged' AND failureReason
    cleared (locks CV5 invariant).

  - CV2 — Arbiter silently disabled when `slots.length === 1`. The N=1
    short-circuit in `runCritic` previously bypassed both
    `mergeCriticFindings` and `maybeRunArbiter`; with the default
    single-entry pool, a strategy enabling `arbiter` got no arbitration
    silently. Lifted out: when N=1, skip the merge but still run the
    arbiter against the sole critic's output. Test in
    `tests/refinement-loop.test.ts` asserts the second `sandbox.run`
    call carries the `-arbiter` nonce.

  - CV3 — `AbortSignal` not propagated through `finalize`. Widened
    `FinalizationConfig.finalize` to `(spec, loopResult, sandbox,
    signal?)`, threaded `ac.signal` from `main.ts`, forwarded to both
    `runValidation` calls, `attemptRebase`, `pushBranch`, and the
    `gh pr create` execFileAsync in `strategies/implement/strategy.ts`.
    Without this, finalize could spend up to 30 minutes (2 ×
    `VALIDATION_TIMEOUT_MS`) past the task abort.

  - CV4 — Post-loop retry round bookkeeping inconsistent.
    `roundsCompleted` is now incremented when the retry round runs,
    `bestSha`/`bestFindingsCount` are updated against the retry's
    non-LOW count, and the corresponding `LoopResult.roundsCompleted`
    JSDoc is corrected to include the retry round. Locked by the new
    end-to-end test asserting `totalCommits ≥ 3` after a 3-round loop +
    retry path.

  - CV5 — `failureReason` invariants and ensemble taxonomy. Added
    `'critic_quorum_failed'` to the union; `checkEarlyExit` now returns
    a discriminating `failureReason` based on `result.validCriticCount`
    (0 → parse-failed, >0 → quorum-failed, commits=0 → actor-error). The
    main loop consumes the discriminated value instead of computing it
    inline. Post-loop retry resets `failureReason = undefined` on every
    `status='converged'` flip so `LoopResult`'s 'reason for non-converged
    termination' invariant holds. New `checkEarlyExit` tests in
    `tests/loop-control.test.ts` (3 cases) lock each branch.

  - CV6 — `maybeRunArbiter` JSDoc claimed null entries excluded; code
    passed them through `JSON.stringify` to the arbiter prompt. Filtered
    at the `runCritic` call site (now passing `validOutputs` of type
    `Finding[][]`); narrowed parameter to `readonly Finding[][]` so the
    type system enforces the contract. Test asserts `PER_CRITIC_FINDINGS`
    prompt arg never contains the literal string 'null'.

  - SO2 — `criticCount < criticPool.length` silently truncated the
    pool. `validateLoopStrategyEnsemble` now rejects the combination
    with field `{ctx}.criticCount` so the violation surfaces at module
    load instead of silently dropping pool entries. Test added.

P2 — consensus 2/3 oracles:

  - CV7 — Removed unreachable `if (result.findings === null) break`
    after `checkEarlyExit` (which already exits on null findings). The
    type narrowing now flows from `?? []` instead of an explicit
    pre-check; behavior unchanged.

  - CV8 — Replaced the default `validate` fallback closure with the
    direct `runValidation` reference. The closure form silently dropped
    its third `signal` arg in favor of an outer-scope capture; both
    arguments resolve to the same value today, but the closure form
    quietly defeated callers that passed a different signal.

  - CV9 — `signal?.throwIfAborted()` added between `runOneCritic`'s
    first attempt and its retry sandbox.run, so an abort that fires
    after the first parse-failure short-circuits before the retry
    spends another sandbox call.

P3 — single-oracle cleanup:

  - SO4 — `loop-control.ts:shouldResetToBest` was duplicating the
    40-hex SHA regex inline; now imports and uses `isValidSha` from
    `utils.ts` (DRY).

Tests: 111 → 120 (+9). Quality gates: `pnpm typecheck`, `pnpm lint` (0
errors, 5 cspell baseline warnings), `pnpm test:sandcastle` (120/120),
`pnpm test` (2401/2401, 6 pre-existing skips), `pnpm build`.

Out of scope (deferred to follow-up, per round-2 plan): `ReasoningEffort`
local type to decouple `AgentSpec.effort` from `PiOptions['thinking']`
(SO3); README marketing-prose trim; critic/arbiter `maxIterations: 1`
exposure; trigger-label inheritance on PRs; remaining single-oracle
nits not in the consensus list.
Copilot AI review requested due to automatic review settings May 20, 2026 21:08
jerome-benoit and others added 30 commits June 8, 2026 23:34
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.

2 participants