Files
ruvnet__ruflo/v3/@claude-flow/cli/__tests__/flywheel-sequential-evidence.test.ts
rUv 0b3cfb77d6 MetaHarness hardening: repair the dependency contract + strict sequential promotion evidence (#2956)
* feat(metaharness): repair dependency contract + strict sequential promotion evidence

Item 1 — dependency & compatibility repair (the contract was silently broken):
- @metaharness/darwin ^0.8.3, @metaharness/flywheel ^0.1.10, and
  @metaharness/radio ^0.1.0 are now explicit optionalDependencies of
  @claude-flow/cli (optional PEER deps are never auto-installed, so a clean
  ruflo install shipped with zero MetaHarness packages on disk)
- check-metaharness-pins.mjs now searches dependencies, optionalDependencies,
  AND peerDependencies; UNDECLARED and PEER-ONLY (for installable pins) are
  fatal drift instead of silently passing; radio added to the watch list;
  --require-installed asserts the real published symbol contracts
  (RefineMutator, withSequentialEvidence, RadioBus, ...)
- new scripts/metaharness-clean-install-test.mjs: installs the declared
  ranges into a pristine temp dir and asserts every advertised export;
  wired as a MANDATORY clean-install job in metaharness-ci.yml
- doctor: new "MetaHarness declared packages" check FAILS (not warns) when a
  declared optional dep does not resolve at runtime; --component metaharness
  now runs upstream + declared-deps + integration checks
- MH_DARWIN_PIN bumped 0.8.0 → 0.8.3 in lock-step with the declared range

Item 2 — strict promotion evidence at the transaction authority:
- receipts now carry task-level pairedOutcomes (taskId + per-task baseline/
  candidate scores) behind heldOutDeltas; verifyFlywheelReceipt refuses rows
  that cannot reproduce their aggregate; evaluateFlywheelCandidate populates
  them; pre-existing receipts still verify byte-identically
- new flywheel-sequential-evidence.ts: anytime-valid e-process over
  discordant paired outcomes (testing-by-betting) composed with per-candidate
  alpha allocation (alpha_k = alpha_total * 6/(pi^2 k^2)), so the family-wise
  false-promotion probability across an ADAPTIVE candidate stream is bounded
  by alpha_total = 5%
- promoteFlywheelCandidate requires paired evidence by DEFAULT — aggregate-
  only receipts are refused, never silently downgraded (the upstream
  withSequentialEvidence fallback hole); explicit
  --allow-aggregate-evidence / allowAggregateEvidence escape hatch for
  pre-upgrade receipts; alpha spend is persisted per receiptId in the
  transaction state (looking spends alpha; retries reuse their index)
- acceptance test: 1,000 null-improvement streams (20 adaptive candidates x
  40 worst-case discordant pairs) — measured family-wise false promotion
  0.6%, within the <=5% budget

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01FKWLVWewYaWmcUg2q8Y9wy

* chore: gitignore node-compile-cache build artifact

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01FKWLVWewYaWmcUg2q8Y9wy

* fix(ci): satisfy #2561 guard, dep-overlap audit, and smoke contract for metaharness optional deps

Three CI failures from the dependency-contract repair, three fixes:

- tilde-pin @metaharness/{darwin,flywheel,radio} (~0.8.3 / ~0.1.10 / ~0.1.0)
  per the ADR-150 anti-caret rule enforced by smoke step 17z73 — upstream is
  unstable, tilde absorbs patches but never minors
- remove the three from peerDependencies/peerDependenciesMeta: an entry in
  BOTH optionalDependencies and peerDependencies crashes npm 11.x arborist
  on dedupe (#1147/#2018 dep-overlap audit); optionalDependencies alone is
  the declaration that actually installs
- update the #2561 cold-startup guard per its own escape clause: budget
  8 → 10 and drop @metaharness/darwin from the forbidden list — that entry
  dated from the pre-0.8 heavy-tree era; darwin@0.8.3 is 1.8M unpacked with
  ZERO dependencies and no lifecycle scripts (all three packages combined:
  2.3M, 753ms cold install into an empty dir, measured 2026-08-10; the
  clean-install CI job re-verifies on every pin change)
- smoke.sh 17h now accepts the componentMap ARRAY form
  ('metaharness': [checkMetaharness, ...]) introduced with the
  declared-packages doctor check

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01FKWLVWewYaWmcUg2q8Y9wy

* fix(ci): remove apostrophe that broke the single-quoted guard script

The #2561 guard runs as `node -e '...'` inside bash; an apostrophe in a
comment ("guard's") terminated the quoted string and bash tried to execute
the next // comment line (exit 126, '//: Is a directory'). Verified by
executing the extracted run block end-to-end: all three checks OK, exit 0.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01FKWLVWewYaWmcUg2q8Y9wy

* feat(flywheel): ADR-381 — alpha-stream governance, reset epochs, accept/v2+seq

Completes the sequential-evidence governance PR #2956 opened:

ADR-381 (new) records the decisions:
- the ADR-322 promotion ledger IS the alpha stream (per project root) —
  receipt lineageIds default to fresh UUIDs and cannot scope the control
- evidence epochs: resetSequentialEvidence({confirm, reason}) archives the
  spend into an append-only sequentialResets audit trail, EXPIRES every
  outstanding evaluated receipt (fresh-data enforcement — old-epoch
  evidence cannot be replayed against a reopened budget), and increments
  evidenceEpoch; surfaced as `flywheel evidence-reset --reason … --confirm`
  (CLI) and the metaharness_flywheel MCP op, both behind the same policy
  gate as promotion
- accept/v2+seq: the ADR-176 generations loop now decides each bundle with
  a third conjunct — the e-process over the bundle's own embedded per-task
  holdout at alpha_k for its position in the attempts stream — with the
  full verdict recorded in the bundle and independently replayed by
  verifyReceiptBundle (v1 bundles keep v1 semantics; versions pin per
  bundle); flywheelStatus surfaces next test index / threshold / minimum
  pairs / remaining budget so exhaustion reads as plateau, not mystery
- two-layer pre-flight: evaluateFlywheelCandidate annotates (never blocks)
  when the promotion holdout cannot clear the next threshold on a perfect
  sweep; promoteFlywheelCandidate refuses size-inviable receipts BEFORE
  allocating an alpha index — sample size is ancillary, so the refusal
  looks at no evidence and spends no budget

Tests: reset semantics (archive/expire/epoch/fresh-promote), alpha-free
size refusal vs alpha-spending e-process refusal, v2 promote/reject/replay
including tamper + index-shopping detection, v1 compat, helper math
anchors (min pairs = 9 at test 1), budget monotonicity. 82 tests green
across the eight affected suites; tsc build clean.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01FKWLVWewYaWmcUg2q8Y9wy

* fix(flywheel): close 3 concurrency/epoch gaps in ADR-381 sequential evidence

Code review of this PR found three real correctness bugs that can silently
violate the <=5% family-wise false-promotion guarantee that is the PR's own
core deliverable:

- flywheel-transaction.ts: resetSequentialEvidence's "fresh data only"
  guarantee only expired receipts already registered at reset time; a
  receipt whose evidence predates a reset but is registered afterward was
  silently admitted into the new, cheaper epoch (index shopping). Now
  tracks evidenceEpochStartedAt and promoteFlywheelCandidate refuses any
  receipt whose payload.issuedAt predates it, before allocating an alpha
  index.

- harness-flywheel-generations.ts: the daemon generations loop computed its
  sequential-evidence testIndex via an unlocked loadAttempts(root).length+1
  read before appending. Two overlapping runFlywheelGeneration calls on the
  same root could be assigned the same test index and spend the same
  alpha_k twice. The read-index -> build-bundle -> append critical section
  now runs under the same O_EXCL lock pattern flywheel-transaction.ts
  already uses.

- harness-flywheel.ts: evaluateFlywheelCandidate's sequentialPreflight
  (backing the `promotable` flag) was computed from an unlocked snapshot
  taken before the async retrieval/scoring work, so it could go stale by
  the time promoteFlywheelCandidate allocates the real index under its own
  lock. Moved to the latest possible read (after receipt registration) to
  minimize the window; documented as advisory, since promoteFlywheelCandidate
  remains the sole authority.

Added a regression test for the epoch-boundary refusal and a concurrency
test proving two racing generations get distinct sequential test indices.

Co-Authored-By: RuFlo <ruv@ruv.net>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-10 11:32:40 -02:30

136 lines
5.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
DEFAULT_ALPHA_TOTAL,
DEFAULT_LAMBDA,
alphaForTest,
checkPairedOutcomesConsistency,
minInformativePairsToClear,
remainingAlphaBudget,
sequentialEvidenceVerdict,
type PairedTaskOutcome,
} from '../src/services/flywheel-sequential-evidence.js';
const win = (i: number): PairedTaskOutcome => ({ taskId: `t${i}`, baselineScore: 0.5, candidateScore: 0.7 });
const loss = (i: number): PairedTaskOutcome => ({ taskId: `t${i}`, baselineScore: 0.7, candidateScore: 0.5 });
const tie = (i: number): PairedTaskOutcome => ({ taskId: `t${i}`, baselineScore: 0.6, candidateScore: 0.6 });
describe('alpha allocation across the candidate stream', () => {
it('sums to at most alphaTotal over arbitrarily many tests', () => {
let spent = 0;
for (let k = 1; k <= 10_000; k++) spent += alphaForTest(k, 0.05);
expect(spent).toBeLessThanOrEqual(0.05);
// and converges to (nearly all of) the budget rather than wasting it
expect(spent).toBeGreaterThan(0.0499);
});
it('rejects invalid indices and budgets', () => {
expect(() => alphaForTest(0)).toThrow(RangeError);
expect(() => alphaForTest(1.5)).toThrow(RangeError);
expect(() => alphaForTest(1, 1)).toThrow(RangeError);
});
it('minInformativePairsToClear is exactly the all-win count that clears the threshold', () => {
for (const k of [1, 2, 5, 10]) {
const n = minInformativePairsToClear(k);
// n all-win pairs clear; n-1 do not.
expect(Math.pow(1 + DEFAULT_LAMBDA, n)).toBeGreaterThanOrEqual(1 / alphaForTest(k));
expect(Math.pow(1 + DEFAULT_LAMBDA, n - 1)).toBeLessThan(1 / alphaForTest(k));
}
// Concrete anchor: test 1 needs 9 net wins at lambda 0.5.
expect(minInformativePairsToClear(1)).toBe(9);
});
it('remainingAlphaBudget decreases monotonically and never goes negative', () => {
let prev = DEFAULT_ALPHA_TOTAL;
for (let k = 0; k <= 50; k += 5) {
const left = remainingAlphaBudget(k);
expect(left).toBeLessThanOrEqual(prev + 1e-12);
expect(left).toBeGreaterThanOrEqual(0);
prev = left;
}
expect(remainingAlphaBudget(0)).toBe(DEFAULT_ALPHA_TOTAL);
});
});
describe('sequential evidence e-process', () => {
it('carries no information in concordant pairs', () => {
const verdict = sequentialEvidenceVerdict([tie(0), tie(1), tie(2)], 1);
expect(verdict.eValue).toBe(1);
expect(verdict.informativePairs).toBe(0);
expect(verdict.significant).toBe(false);
});
it('reaches significance on a strong candidate at test 1 but not at test 2', () => {
const outcomes = Array.from({ length: 10 }, (_, i) => win(i)); // e = 1.5^10 ≈ 57.7
const first = sequentialEvidenceVerdict(outcomes, 1);
expect(first.significant).toBe(true);
expect(first.threshold).toBeCloseTo(1 / alphaForTest(1), 6);
const second = sequentialEvidenceVerdict(outcomes, 2); // threshold ≈ 131.6
expect(second.significant).toBe(false);
});
it('is order-invariant and symmetric between wins and losses', () => {
const mixed = [win(0), loss(1), win(2), win(3)];
const shuffled = [win(3), win(0), win(2), loss(1)];
expect(sequentialEvidenceVerdict(mixed, 1).eValue).toBeCloseTo(sequentialEvidenceVerdict(shuffled, 1).eValue, 12);
// one win and one loss cancel: (1.5)(0.5) = 0.75 < 1
expect(sequentialEvidenceVerdict([win(0), loss(1)], 1).eValue).toBeCloseTo(0.75, 12);
});
});
describe('paired-outcome consistency', () => {
const outcomes: PairedTaskOutcome[] = [
{ taskId: 'a', baselineScore: 0.5, candidateScore: 0.6 },
{ taskId: 'b', baselineScore: 0.4, candidateScore: 0.55 },
];
it('accepts outcomes that reproduce their aggregate deltas', () => {
expect(checkPairedOutcomesConsistency(outcomes, [0.1, 0.15]).ok).toBe(true);
});
it('refuses length mismatch, duplicate ids, and irreproducible deltas', () => {
expect(checkPairedOutcomesConsistency(outcomes, [0.1]).ok).toBe(false);
expect(checkPairedOutcomesConsistency([outcomes[0], { ...outcomes[1], taskId: 'a' }], [0.1, 0.15]).ok).toBe(false);
expect(checkPairedOutcomesConsistency(outcomes, [0.1, 0.2]).ok).toBe(false);
expect(checkPairedOutcomesConsistency([], []).ok).toBe(false);
});
});
describe('acceptance: family-wise false promotion under the null', () => {
it('stays at or below the 5% budget across 1,000 null-improvement streams', () => {
// Each stream simulates an ADAPTIVE flywheel run under the global null
// (no candidate is truly better): 20 candidates per stream, 40 paired
// tasks per candidate, every pair discordant with P(candidate wins) = 1/2
// — the worst case for the e-process, since concordant pairs carry no
// signal. A false promotion is ANY candidate in the stream clearing its
// allocated threshold. The theoretical bound is
// sum_k alpha_k = alphaTotal = 5%.
let seed = 0x5eed5eed >>> 0;
const rnd = () => {
seed = (1664525 * seed + 1013904223) >>> 0;
return seed / 4294967296;
};
const STREAMS = 1_000;
const CANDIDATES_PER_STREAM = 20;
const PAIRS_PER_CANDIDATE = 40;
let streamsWithFalsePromotion = 0;
for (let s = 0; s < STREAMS; s++) {
let promotedFalsely = false;
for (let k = 1; k <= CANDIDATES_PER_STREAM; k++) {
const outcomes: PairedTaskOutcome[] = Array.from({ length: PAIRS_PER_CANDIDATE }, (_, i) =>
rnd() < 0.5 ? win(i) : loss(i));
if (sequentialEvidenceVerdict(outcomes, k).significant) {
promotedFalsely = true;
break;
}
}
if (promotedFalsely) streamsWithFalsePromotion++;
}
const rate = streamsWithFalsePromotion / STREAMS;
expect(rate).toBeLessThanOrEqual(DEFAULT_ALPHA_TOTAL);
});
});