feat(research-loop-cap): give the discovery ceiling a reader, not just a sentence
The bounded-cost NFR asks for explicit ceilings on BOTH axes - max
conversation turns and max discovered dimensions. The turn axis got
MAX_CONV_TURNS, a ledger-backed reader and a PreToolUse enforcer. The
discovery axis got one sentence in Phase 4.5 prose ("append candidates only
while the whole list stays at or below maxDimensions: 8") with no constant
of its own, no reader, and no test that a run exceeding it is caught. That
is the brief_reviewer_iter_cap shape the operator decision warned about: a
cap nothing reads.
checkDimensionCeiling() is the reader, exposed on the CLI as
--check-dimensions N (exit 0 within, exit 1 rejected), and Phase 4.5 step 3
now calls it once the final list is settled instead of merely describing the
bound.
Three deliberate choices:
- The ceiling IS MAX_TOTAL_DIMENSIONS, the constant that sizes the turn
budget. Both axes read one settings.json:16 value, so they cannot end up
enforcing different numbers - a second constant is how that drift starts.
- An unreadable count is REJECTED ('abc', null, undefined, {}, -1, NaN,
non-integers). A cost ceiling that waves through what it cannot measure is
not a ceiling.
- --check-dimensions requires no run id, effort or VOYAGE_STORM_ENABLED.
Phase 4.5 never calls the budget gate - that is why its skip-guard reads
the flag directly - so the ceiling check must not inherit the gate's
preconditions.
The mitigation the review already verified still holds and is unchanged:
MAX_TOTAL_DIMENSIONS bounds actual retrieval cost regardless of how many
dimensions discovery appends. What was missing was anything that FAILS on a
list over the bound, and now a run over it is rejected by exit code.
Review finding 96a3ee51152dfe72aca703f771843f2f3639e7b6 (MINOR).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
This commit is contained in:
parent
8c60d2079e
commit
7dc0add768
4 changed files with 137 additions and 2 deletions
|
|
@ -18,6 +18,7 @@ import {
|
|||
resolveLedgerPath,
|
||||
resolveDataRoot,
|
||||
readLedger,
|
||||
checkDimensionCeiling,
|
||||
MAX_CONV_TURNS,
|
||||
MAX_TOTAL_DIMENSIONS,
|
||||
} from '../../lib/util/research-loop-cap.mjs';
|
||||
|
|
@ -486,6 +487,72 @@ test('readLedger — a tombstone is reported separately and never as a granted t
|
|||
});
|
||||
});
|
||||
|
||||
// ---- the discovery ceiling has a reader, not just a sentence ----------------
|
||||
//
|
||||
// The bounded-cost NFR asks for explicit ceilings on BOTH axes: max conversation
|
||||
// turns and max discovered dimensions. The turn axis got MAX_CONV_TURNS, a
|
||||
// ledger-backed reader and a PreToolUse enforcer. The discovery axis got a
|
||||
// sentence in Phase 4.5 — "append candidates only while the whole list stays at
|
||||
// or below maxDimensions: 8" — with no constant of its own, no reader, and no
|
||||
// test that a run exceeding it is caught. That is the same shape as the
|
||||
// brief_reviewer_iter_cap failure the operator decision warned about: a cap
|
||||
// nothing reads.
|
||||
//
|
||||
// The ceiling is deliberately the SAME constant that sizes the turn budget. Two
|
||||
// constants for one settings.json:16 value is how the two drift apart.
|
||||
|
||||
test('checkDimensionCeiling — a list at the ceiling is accepted', () => {
|
||||
const r = checkDimensionCeiling(Array.from({ length: MAX_TOTAL_DIMENSIONS }, (_, i) => `d${i}`));
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.count, MAX_TOTAL_DIMENSIONS);
|
||||
assert.equal(r.ceiling, MAX_TOTAL_DIMENSIONS);
|
||||
});
|
||||
|
||||
test('checkDimensionCeiling — one dimension over the ceiling is REJECTED', () => {
|
||||
const r = checkDimensionCeiling(Array.from({ length: MAX_TOTAL_DIMENSIONS + 1 }, (_, i) => `d${i}`));
|
||||
assert.equal(r.ok, false, 'a ceiling that accepts ceiling+1 is not a ceiling');
|
||||
assert.equal(r.reason, 'ceiling_exceeded');
|
||||
assert.equal(r.count, MAX_TOTAL_DIMENSIONS + 1);
|
||||
});
|
||||
|
||||
test('checkDimensionCeiling — a plain count works as well as a list', () => {
|
||||
assert.equal(checkDimensionCeiling(8).ok, true);
|
||||
assert.equal(checkDimensionCeiling(9).ok, false);
|
||||
assert.equal(checkDimensionCeiling('8').ok, true);
|
||||
});
|
||||
|
||||
test('checkDimensionCeiling — an unreadable count is rejected, never waved through', () => {
|
||||
for (const bad of ['abc', null, undefined, {}, -1, NaN]) {
|
||||
const r = checkDimensionCeiling(bad);
|
||||
assert.equal(r.ok, false, `${JSON.stringify(bad)} must not pass a cost ceiling`);
|
||||
assert.equal(r.reason, 'unreadable_dimension_count');
|
||||
}
|
||||
});
|
||||
|
||||
test('checkDimensionCeiling — the ceiling is the same constant that sizes the turn budget', () => {
|
||||
// Phase 4.5 and the Phase 5 budget must not be able to disagree about 8.
|
||||
assert.equal(checkDimensionCeiling(0).ceiling, MAX_TOTAL_DIMENSIONS);
|
||||
});
|
||||
|
||||
test('CLI shim — --check-dimensions exits 0 at the ceiling and 1 above it', () => {
|
||||
const at = runShim(['--check-dimensions', String(MAX_TOTAL_DIMENSIONS)], {});
|
||||
assert.equal(at.code, 0, `at the ceiling must exit 0; got ${at.out}`);
|
||||
assert.equal(JSON.parse(at.out.trim()).ok, true);
|
||||
|
||||
const over = runShim(['--check-dimensions', String(MAX_TOTAL_DIMENSIONS + 1)], {});
|
||||
assert.equal(over.code, 1, 'a run over the ceiling must be rejected by exit code, not by prose');
|
||||
const parsed = JSON.parse(over.out.trim());
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.equal(parsed.reason, 'ceiling_exceeded');
|
||||
});
|
||||
|
||||
test('CLI shim — --check-dimensions needs no runId, effort or STORM flag', () => {
|
||||
// It is a cost ceiling on Phase 4.5, which never calls the budget gate, so it
|
||||
// must not inherit the budget gate's preconditions.
|
||||
const r = runShimStripped(['--check-dimensions', '3'], {});
|
||||
assert.equal(r.code, 0, `should not require --run-id/--effort; got ${r.out}`);
|
||||
});
|
||||
|
||||
// ---- (g) shim contract --------------------------------------------------------
|
||||
|
||||
test('CLI shim — grants and exits 0 when enabled + high effort + budget available', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue