Repository navigation
fix(oracle-gaps): report a shortcut for speed as extra-work, and let a work bound kill it - #48
Merged
Merged
Conversation
…-observed gap The classify oracle gains the work law: each test's work (site evaluations, never time), whether it varied between the unmutated runs, a declared work bound, and what each mutant adds to the work. A survivor whose work rose in a test that judges work is extra-work; a test past its bound reports a verdict under law work bound on its route. The self-oracle gains oracleProperty's work option: a ratio below 1 or not finite fails to load, and every test whose route judges production registers the ratio through __oracleWorkBound, and no calibration does. The end-to-end model counts work per site evaluation and gains shortcut witnesses (an early return and a skipped String call), each with a bound twin, and a warm-cache witness for selections. RED on the parent: the classify oracle reports not-observed where extra-work and work-bound kills are owed; the self-oracle reports that no work bound is registered and that invalid ratios load. Co-authored-by: Isaac <no-reply@databricks.com>
…a work bound kill it
A survivor that changed no observation but made a test do more work
(site evaluations, the capture's call count) is now extra-work, not
not-observed. Its directive names the test, its work before and after,
and the work bound that rejects the mutant. oracleProperty takes
work: { ratio }, which it registers beside each route that judges
production; under the gap CLI a test whose work under a mutant passes
that ratio of its unmutated work kills it under law work bound. A plain
Vitest run has no unmutated run, so there the bound judges nothing.
Co-authored-by: Isaac <no-reply@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
The warm-cache witness w_cold moves to the end of file a, after every nondeterministic test, so its selection is otherwise reproducible. Its drop-not mutant writes to the cache again in the whole file (extra-work), but a selection that skips w_unseen's warming test finds the cache cold and does no extra work. RED on the parent: src/sut.ts:w_cold:231:drop-not#0: not-observed, owed extra-work. The e2e limits now state what the work law covers. Co-authored-by: Isaac <no-reply@databricks.com>
…' work A selection that leaves out the tests before a mutant's first reaching test is reused only when its unmutated run reproduces what the whole file recorded. It now also has to reproduce each test's work, the call count that a work bound and the extra-work class judge. A skipped test that warmed a cache otherwise makes a later test do more work and hides a mutant's extra work. Co-authored-by: Isaac <no-reply@databricks.com>
…together Co-authored-by: Isaac <no-reply@databricks.com>
🦋 Changeset detectedLatest commit: fa88316 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
…red exactly Review of #48 (F1, F2, F3, F4, F5): - The classify model and the e2e model judge work only on a reproducible or random route, which can carry a bound. A grammar route that no oracleProperty registers, and a constructed case where such a test rises before a registered one, which the directive must name. - The bound grid in hundredths (1.00, 1.16, 1.50, 2.00) and the work grid (0, 10, 25, 100; +4) reach the exact boundary 29 = 1.16 x 25, which the model compares in integers. - A test that made a timer or held I/O open judges no work (classify grammar dimension; e2e witness w_fasttimed, whose tests wait on a timer, and w_fastplain, whose tests register no route). - The printed ratio has three decimals; a null work option fails to load with the work.ratio message. RED at ba822f0: classify oracle 18 failed (extra-work owed not-observed; killed by work bound owed extra-work at 29 = 1.16 x 25; the directive names the unregistered t0); self-oracle "Cannot read properties of null (reading 'ratio')". Co-authored-by: Isaac <no-reply@databricks.com>
commit: |
…exact hundredths Review of #48: - F1: only a test on a reproducible or random route judges work, since only such a test can carry a bound; a rise on an unregistered route leaves the mutant's class as it was, and the directive names the first registered test that rose. - F3: the bound is compared exactly, 100 x calls > round(100 x ratio) x before, the arithmetic the recommended bound already used. - F5: a test that made a real timer or interval (the capture now records it) or held I/O open judges no work: its count can follow the clock. - F2: the measured ratio prints three decimals. - F4: work: null fails to load with the work.ratio message. - F7: the option and the README say that the bound is the property's and judges every test it registers. Co-authored-by: Isaac <no-reply@databricks.com>
…s owed A builtin call counts as one site evaluation whatever it costs, so a shortcut whose cost is in one (defineProperty) is owed an observation of that cost, not a very tight bound (maintainer, 2026-11-12). RED: "the extra-work legend line does not say that a builtin counts as one site". Co-authored-by: Isaac <no-reply@databricks.com>
… tighten a bound The extra-work legend and the README now say that a builtin call counts as one site evaluation whatever it costs, so a shortcut whose cost is in one is owed an observation of that cost in the driver, such as a count of those calls, judged by a law, in place of a very tight work bound (maintainer, 2026-11-12). Co-authored-by: Isaac <no-reply@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
Re-review of #48 (F5): a kill must replay on an idle machine (P3), so a test whose count could follow the clock judges no work (maintainer decision, 2026-11-12). The classify grammar's clocked dimension now covers a timer, a node:timers/promises timer, a clock read, and held I/O, all recorded as one row flag. The e2e gains witnesses whose tests sleep through node:timers/promises (w_fastsleep) or read Date.now() (w_fastclock), owed not-observed under a bound of 1, and one whose tests wait a turn with setImmediate and read no clock (w_fastturn), still judged, owed killed. RED at 9087746: classify oracle 8 failed ("killed laws [work bound], owed not-observed"; "extra-work, owed not-observed"). Co-authored-by: Isaac <no-reply@databricks.com>
Re-review of #48 (F5): a work-bound kill must replay on an idle machine (P3), so a test whose count could follow the clock judges no work (maintainer decision, 2026-11-12). The capture now records one row flag, clocked, when the test made a real timer or interval through the globals, node:timers, or node:timers/promises, read a clock (Date.now, new Date() or Date() with no argument, performance.now, process.hrtime), or held I/O open. A read by the test runner, fast-check, the oracle framework, Node, or the capture itself does not count, nor does one under a fake clock; a setImmediate turn reads no clock. classify reads the one flag, and its route, clock, and rise checks are one closure that the bound's check reuses. GREEN: classify oracle 255/255; e2e "every outcome class" passed; klona with bounds still 218/218 killed. Mutants: today's flag set ("w_fastsleep killed, owed not-observed"); no Date.now patch ("w_fastclock killed, owed not-observed"); immediates counted as clocks ("w_fastturn not-observed, owed killed"). Co-authored-by: Isaac <no-reply@databricks.com>
…, under the capture Round-3 review of #48 (R1, R2, R3). The e2e's clock witnesses become one function, w_fastclocked, whose tests each reach the clock by one path before they run production: util.promisify of the global and of node:timers's setTimeout (which must keep its value, law 3: a test that passes alone passes under the CLI), node:timers/promises, Date.now() directly and through a vi.spyOn passthrough, process.uptime(), os.uptime(), AbortSignal.timeout, Atomics.waitAsync with a timeout, and monitorEventLoopDelay. Its if-false mutant is owed not-observed under a bound of 1, so any path left unseen kills it. The classify grammar's clocked dimension names the same paths. Co-authored-by: Isaac <no-reply@databricks.com>
… and judge a read by its whole stack Round-3 review of #48: - R1: the capture also tells a test that polls process.uptime, os.uptime, performance.eventLoopUtilization, AbortSignal.timeout, Atomics.waitAsync with a timeout, monitorEventLoopDelay, or Temporal.Now. - R2: every wrapper (the global timers and the clock reads) keeps the own properties of the function it wraps, util.promisify.custom included, so util.promisify(setTimeout) resolves with its value under the CLI, in the unmutated baseline too (the global wrapper on main dropped it). - R3: a clock read counts when any frame of its stack is the test's or production's, so a user's read through a vi.spyOn passthrough (tinyspy's frame first) counts. Flagging every read (variant a) was measured on klona with bounds: 216 of 218 killed, json M16 and full M17 lost, because the oracle framework's own reads flag the tests that carried those rises; so reads whose every frame is the runner's, fast-check's, or the framework's still do not count. RED at b21d188: e2e "every outcome class": "devBuild ... blocked, owed not-observed" (file a's baseline fails: promisify of the wrapped timer throws). GREEN after. Mutants: a wrapper that drops symbol properties (blocked, as at b21d188); no process.uptime patch ("w_fastclocked if-false killed, owed not-observed"); the first frame decides (the same). Klona with bounds: 218 of 218 killed. Co-authored-by: Isaac <no-reply@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
…the clock Round 4 of the #48 review: w_fastclocked gains a test that waits with Atomics.wait and a timeout, and the classify grammar names it. Co-authored-by: Isaac <no-reply@databricks.com>
…clock flag Round 4 of the #48 review: Atomics.wait with a timeout returns by the clock, or by another thread's timing, so it sets clocked as Atomics.waitAsync does. RED at cfa10b7: e2e "every outcome class", "w_fastclocked:101:if-false killed, owed not-observed". GREEN after. Co-authored-by: Isaac <no-reply@databricks.com>
At 661d2fd the second fixed-seed job ran 10.8 min: CI's relative unit costs differ from a local run's (brokenOwner, generated, and every outcome class each cost about 3 times their local time there). The units are packed longest first by the costs read from the three jobs' logs: about 427 s, 421 s, and 429 s. Co-authored-by: Isaac <no-reply@databricks.com>
…, and keep error.log A drawn work dimension in the e2e's generated test record shifted fast-check's stream, so every generated base, and every constructed history built on one, changed and grew heavier: generated 23 s on main to 172 s in CI. The bound is now derived from the plan (1 when the test's input is a multiple of 3), with fc.constant(null) in the record, so the grammar's histories are main's again. Full fixed seed, locally: 827 s with the drawn bound, 492 s derived (generated 93 s to 11 s, noTypescript 73 s to 2 s). Two measured states count how often a generated function reaches the work law: 0 with the drawn bound and 0 derived, across the 36 fixed-seed units; the e2e's work-law reach comes from the witness module either way (extra work 6 and 3, killed by a work bound 6 and 3). A run that exits 3 now keeps the start of its error.log in the observed stderr, so a "did not complete" difference carries the internal error's stack (#56). Co-authored-by: Isaac <no-reply@databricks.com>
Keeps #46's line protocol and law 15 with the work law: the extra-work gap kind in the streamed-gap pattern, the extra-work legend line under the new oracle-gaps: legend prefix, and the README's work law before the protocol section. Co-authored-by: Isaac <no-reply@databricks.com>
…red costs With the work bound derived, the generated histories are main's again, so the shards follow main's CI costs (ae3184d): about 290 s, 277 s, and 260 s of CI time, and 135 s, 125 s, and 144 s locally. Co-authored-by: Isaac <no-reply@databricks.com>
KyleAMathews
added a commit
that referenced
this pull request
Nov 13, 2026
The witness module keeps both sides: w_draws beside #48's work-law witnesses (w_fast*, w_fastclocked, w_fastturn, w_fastplain, and w_cold, still last in file a), and the test record's draws constant beside work and timed. Co-authored-by: Isaac <no-reply@databricks.com>
KyleAMathews
added a commit
that referenced
this pull request
Nov 13, 2026
The fs recorder's wrappers reuse #48's keepProperties. Co-authored-by: Isaac <no-reply@databricks.com>
KyleAMathews
added a commit
that referenced
this pull request
Nov 13, 2026
…the work bound (#48) Co-authored-by: Isaac <no-reply@databricks.com>
This was referenced Nov 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The
oracle-gapsCLI reported a production branch that exists only for speed as anot-observedgap. Its directive (observe the value, or widen the oracle) cannot work when the fast path and the general path give the same result. An author who followed the "remove code no law needs" advice deleted the shortcut and made the code slower. The maintainer ruled this a gap CLI bug (2026-11-12).This PR gives such a survivor its own class,
extra-work, and adds a cost law that can kill it:extra-work. A survivor with no changed observation, where a test whose work judges the mutant did more work. Work is the capture's count of site evaluations per test (calls), a deterministic count. Wall-clock time is never used.Object.defineProperty) is owed an observation of that cost in the driver, such as a count of those calls, judged by a law, in place of a very tight bound.oracleProperty({ work: { ratio } }). Under the gap CLI, a passing test whose work under a mutant is more thanratiotimes its unmutated work reports a verdict under lawwork boundon its route. On a reproducible route that kills the mutant.check.mjs). A selection that leaves out earlier tests is reused only when it reproduces each test's work as well as its observations. Before, a skipped test that warmed a cache hid a mutant's extra work.Design
calls(one per mutation-site evaluation in a test). Call statements and calls whose value is used are sites, so an added call and a removed early return both move it. It does not see plain assignments,for...ofpasses, a builtin's own cost, or uninstrumented code.callsagree between the two unmutated runs, no run of it could have a count that follows the clock (row flagclocked), and its route is reproducible or random: a route thatoraclePropertyregisters for production, which can carry a bound. A rise elsewhere leaves the mutant's class as it was.not-judged,indeterminate,extra-work, then the static classes. A survivor whose work fell staysnot-observed.clockedwhen the test made a real timer or interval through the globals,node:timers,node:timers/promises,AbortSignal.timeout, orAtomics.waitorAtomics.waitAsyncwith a timeout; read a clock (Date.now,new Date()orDate()with no argument,performance.now,performance.eventLoopUtilization,process.hrtime,process.uptime,os.uptime,Temporal.Now); usedmonitorEventLoopDelay; or held I/O open. A read counts when any frame of its stack is the test's or production's, so a read through avi.spyOnpassthrough counts; a read whose every frame is the test runner's, fast-check's, the oracle framework's (its shrink budget), Node's, or the capture's does not, nor does one under a fake clock. AsetImmediateturn reads no clock. Every wrapper keeps the own properties of what it wraps,util.promisify.customincluded.100 × calls > round(100 × ratio) × before.work boundfailures joinoutcomeOf's failures in report order, so they rank as law verdicts do:killedon a reproducible route,random-only-killon the random campaign. They outrank a timeout, a crash, andnot-judged. The runner's stop-at-first-kill still reads only law verdicts.work.ratiomust be a finite number of at least 1, or registration throws (also forwork: null). Every test whose route is notcalibrationcalls__oracleWorkBound(ratio). The bound is the property's, so it judges every test the property registers, though the directive names one of them.Maintainer decisions, 2026-11-12
extra-workclass and its directive already make every such survivor visible. The 100× call budget remains only as the spin cutoff.generatedunit) is judged by their sum, so a slowdown in a few histories is diluted. This is accepted as a limit and stated in the README.floor((100·after − 1) / before) / 100, or 1 when the test did no work before. This is the bound least likely to fail a later legitimate change.Review fixes (independent review of ba822f0)
extra-workwith a bound it cannot carryextra-work, owed not-observed; the directive named the unregisteredt0. E2e:w_fastplain!== framework: 3 classification and 2 directive failures100 × calls > round(100 × ratio) × before; grids now reach 29 = 1.16 × 25killed by work bound, owed extra-workat 25 → 29 under 1.16clocked); e2e:src/sut.ts:w_fasttimed:101:if-false#0: killed, owed not-observedDate.now()/performance.now()loop,node:timers/promises, asetImmediateloop that reads a clockclockedflag;timersandioare merged into it, and classify's route, clock, and rise checks are one closure that the bound reuseskilled laws [work bound], owed not-observed); e2e:src/sut.ts:w_fastsleep:110:if-false#0: killed, owed not-observedw_fastsleep killed, owed not-observed; noDate.nowpatch:w_fastclock killed, owed not-observed; immediates counted as clocks:w_fastturn not-observed, owed killedwork: nullthrew a TypeErrorCannot read properties of null (reading 'ratio')process.uptime,os.uptime,AbortSignal.timeout,Atomics.waitAsync,monitorEventLoopDelayescaped the flagtoldwrapper (alsoeventLoopUtilization,Temporal.Now)w_fastclocked(tests that reach the clock by each path)process.uptimepatch:w_fastclocked:101:if-false killed, owed not-observedutil.promisify.custom, soutil.promisify(setTimeout)threw, in the baseline too (also the global wrapper on main)keepPropertiescopies every own property onto each wrapperdevBuild:4:binary-swap blocked, owed not-observed(file a's baseline fails onpromisify(setTimeout)(0, 'v'))vi.spyOnpassthrough was excludedw_fastclockedw_fastclocked:101:if-false killed, owed not-observedAtomics.waitwith a timeoutAtomics.waitAsyncis; a classify grammar entry and aw_fastclockedtestw_fastclocked:101:if-false killed, owed not-observedError.stackTraceLimit = 50workdimension in the e2e's generated test record shifted fast-check's stream, so every generated base and every constructed history built on one grew heavier (CI, main → this PR:generated23 s → 172 s,noTypescript4.5 s → 113 s). The stack capture was not the cost (locally: 58.7/65.2 s with it, 69.7/75.1 s flagging every read without it). The bound is now derived from the plan (1 when a generated test's input is a multiple of 3), withfc.constant(null)in the record, so the grammar's histories are main's again. Full fixed seed locally: 827 s drawn, 492 s derivedthe extra-work legend line does not say that a builtin counts as one siteEvidence
extra-work, and its directive names the bound that rejects it. A declared work bound kills a mutant whose per-test work is more thanratio ×the unmutated work. Authority: the maintainer's decision of 2026-11-12 ("this is a bug then in the gap cli and should be fixed"), and the design decided with it: a cost law, no exit, judged by a deterministic count.extra work in a generated functionandkilled by a work bound in a generated function, summed over the 36 fixed-seed units): 0 and 0 with the drawn bound, 0 and 0 with the derived one. Deriving the bound drops no coverage: no generated function's mutant was a shortcut for speed either way, since the generated statements have no fast path that gives the general path's result. The e2e's work-law reach comes from the witness module (extra work6 drawn and 3 derived,killed by a work bound6 and 3, by unit count), and the classify oracle's generator covers the law broadly (counts below). A generated shortcut statement kind would reach it, at the cost of a new stream; it is not in this PR.extra-work6,extra-work from no work3,killed by a work bound3,a work bound kill beside a changed value1,random-only-kill by a work bound3,a work rise where work judges nothing4,a survivor whose work fell3.extra-work under a declared boundis reached by its constructed history only (0 in generated runs).work-bound-registeredandwork-bound-left-out-of-calibrationare reached by fixed histories. The small generated campaign reached them 0 times.fixed seed 20261105: every outcome class:extra work1,killed by a work bound1, with the witnessesw_fast,w_fastbound,w_fastcall,w_fastcallbound, andw_cold.vitest --run --config oracle-gaps/vitest.config.mjs gap-report-classify: 16 failed, for examplemutant m0: ["survived","not-observed",null] owed ["survived","extra-work",null]and… owed ["killed",null,null] laws ["work bound"].vitest --run --config vitest.oracle-framework.config.mjs -t "a work bound": 3 failed, with"fixed seed 7: generated" ran [1,0] with work bound null registered, owed 1.25, and ratios of 0.5 and Infinity loaded.-t "fixed seed 20261105: every outcome class", before thecheck.mjsfix:src/sut.ts:w_cold:231:drop-not#0: not-observed, owed extra-work.tsc --noEmit: clean. ESLint: clean on the changed files.fixed seed 20261105: every outcome class: passed.-t "fixed seed 20261105: "): 36/36 at 35735d9 (581 s locally, verbose, used for the shard costs). Random-seed and other units run in CI.pr-shards check("3 partitions split their tests exactly") ande2e-shards check("5 shards partition the e2e tests") pass. They ran through an exec-onlypnpmwrapper, with no install.ms(wall time) instead ofcalls.w_fast:75:if-false: not-observed, owed extra-work.w_fastbound:84:if-false: extra-work, owed killed.generatedunit's histories are summed.for...ofpass counts nothing.Temporal.Nowis wrapped when present, which this Node (v24) cannot confirm, since it has noTemporal; a reference to a clock or timer taken before the harness ran (a module that loads before the setup file and keepsDate.now; the harness is the first setup file), a read whose every frame is an excluded library's (vitest, fast-check,@tanstack/oracles, tinyspy, tinypool, chai, or a directory namedpackages/oracles/src),Atomics.wait(synchronous), a native addon's clock, and a Worker's. The I/O signal is sampled every 50 ms, so a very short I/O can be missed. A read with any frame of the test file, production, or another dependency is watched, so a test loses work judging rather than giving a kill that does not replay.vi.useFakeTimersinstalled after the patch restores the patched functions on uninstall; that path is checked by inspection only.String(bound 1), the early return in tests that wait on a timer (bound 1) and in tests that register no route, a generated bound of 1, and one warm-cache selection witness. No e2e history has a test whose work varies between runs while its observations agree.every outcome classon a slow runner (run 43346685892): not reproduced locally under tight limits or CPU contention, and the next occurrence's stack is kept by the driver now (error.login the observed stderr). Tracked as oracle-gaps: an internal error (exit 3) in CI during the e2e stability rerun under load #56, where this PR's capture wrappers are named as one candidate cause.determinism: constructed history: every outcome class. Thestoppedobservation'ssessionslists hold.oracle-gaps-<pid>, which differ between the two runs. That code came from fix(oracle-gaps): keep the daemon's state, bundles, and default output in the OS temp directory #43.random seed: a failing owner under a config that --config namesdid not enterblocked by brokenOwner. Replay:ORACLE_SEED=520648412 ORACLE_PATH=0 ORACLE_PROPERTY='oracle-gaps.report' ORACLE_UNIT='random seed: a failing owner under a config that --config names'. Its fixed-seed twin passes, and its counterexample declares no work bound.--seedclassified all 10 shortcuts asnot-judged, notextra-work. In every case the changed observation was in the old tarball's singlerandom seedtest of each mode. No test was marked nondeterministic, and no other mutant changed that test.--seed,oracleCampaignSeeddraws the campaign seed fromMath.random, so the seed can differ between runs, and the two-run agreement does not check it. It is a CLI determinism bug, investigated in the follow-up PR (a likely fix: the CLI pins one seed for every run of a check, and reports it).--seed 1(as in the original r16 run), all 10 areextra-work.Klona validation
This used a copy of the r16 repository. The original was not edited. The run was
--seed 1, with 218 mutants.key in obj → true&& → ||&& → ||key in obj → truekey in obj → true!val.enumerable … → truehasOwnProperty … → falsenot-observedand 2not-distinguished). After it, all 10 areextra-work.work: { ratio }on the four properties (json 1.06, lite 1.14, default 1.08, full 1.01), with this PR's framework hook ported into the copy's installed build. The plain Vitest run still passes (78 tests). The gap check reports 218 of 218 mutants killed and 0 gaps, andwork boundis the first law to reject 11 of them. After the review fixes, the same run again gives 218 of 218 killed and 0 gaps.Size
Production code, compared with
origin/main, minified with esbuild and gzipped:oracle-gaps/classify.mjsoracle-gaps/report.mjsoracle-gaps/capture-setup.mjsoracle-gaps/check.mjssrc/oracle-property.tssize:comparescript, sosize:comparewas not measured; these are esbuild-minified, gzipped file measurements, not an app bundle. The clock rule's capture patches (every clock source, property keeping, and the stack rule) are about +670 B of it; merging the flags and foldingroseinto the bound's closure saved 18 B in classify. A perturbed-clock comparison would need the same patches (round 3's review), so it is not lighter. The CLI ships as source, so its shipped gzip, comments included, grows by about +1.8 KB. Includingoracle-property.ts, the total is about +2.5 KB. Most of the growth after review is the legend's builtin guidance (+115 B) and the route, timer, and exact-comparison rules.reached.addto a countingreach.This pull request and its description were written by Isaac.