Skip to content

Commit 82ea2a8

Browse files
committed
fix: tighten adaptive failure correlation
1 parent 9d921ba commit 82ea2a8

2 files changed

Lines changed: 64 additions & 20 deletions

File tree

packages/runtime-playground/src/browser-adaptive-explorer.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ interface FrontierEntry {
4545
path: BrowserAdaptiveAction[]
4646
}
4747

48+
interface AdaptiveNetworkFailureRetention {
49+
remainingCount: number
50+
remainingBytes: number
51+
}
52+
4853
export async function exploreAdaptiveBrowserStateMachine({
4954
page,
5055
baseUrl,
@@ -79,6 +84,10 @@ export async function exploreAdaptiveBrowserStateMachine({
7984
let revisits = 0
8085
let keyboardActions = 0
8186
let exhausted: BrowserAdaptiveExplorationResult["summary"]["budgetExhausted"]
87+
const networkFailureRetention: AdaptiveNetworkFailureRetention = {
88+
remainingCount: Math.min(contract.budgets.maxErrors, contract.descriptorLimits.maxDiagnostics),
89+
remainingBytes: Math.floor(contract.budgets.maxArtifactBytes / 8),
90+
}
8291

8392
const initial = await captureAdaptiveState(page, contract, 0, navigationScope)
8493
appendDiagnostics(diagnostics, initial.diagnostics, contract.descriptorLimits.maxDiagnostics)
@@ -144,7 +153,7 @@ export async function exploreAdaptiveBrowserStateMachine({
144153
const newConsoleRecords = consoleErrorRecords(observations.consoleMessages.slice(beforeConsole))
145154
const newConsoleErrors = newConsoleRecords.map(recordMessage)
146155
const newPageErrors = errorMessages(observations.errors.slice(beforeErrors))
147-
const oracleEvidence = adaptiveOracleEvidence(newConsoleRecords, observations.network.slice(beforeNetwork), newPageErrors, contract, networkPolicy)
156+
const oracleEvidence = adaptiveOracleEvidence(newConsoleRecords, observations.network.slice(beforeNetwork), newPageErrors, contract, networkPolicy, networkFailureRetention)
148157
const fingerprints = [...oracleEvidence.fingerprints]
149158
const existing = states.get(stabilized.state.digest)
150159
const newState = !existing
@@ -570,7 +579,7 @@ function consoleErrorRecords(records: object[]): Record<string, unknown>[] {
570579
return records.map((record) => record as Record<string, unknown>).filter((record) => record.type === "error" || record.level === "error").slice(0, 100)
571580
}
572581

573-
function adaptiveOracleEvidence(consoleRecords: Record<string, unknown>[], networkRecords: object[], pageErrors: string[], contract: BrowserAdaptiveExplorationContract, networkPolicy?: BrowserPreviewNetworkPolicy): { fingerprints: string[]; networkFailures: BrowserAdaptiveNetworkFailure[]; networkFailureSummary?: BrowserAdaptiveTransition["observations"]["networkFailureSummary"]; errorCount: number } {
582+
function adaptiveOracleEvidence(consoleRecords: Record<string, unknown>[], networkRecords: object[], pageErrors: string[], contract: BrowserAdaptiveExplorationContract, networkPolicy?: BrowserPreviewNetworkPolicy, retention?: AdaptiveNetworkFailureRetention): { fingerprints: string[]; networkFailures: BrowserAdaptiveNetworkFailure[]; networkFailureSummary?: BrowserAdaptiveTransition["observations"]["networkFailureSummary"]; errorCount: number } {
574583
const failures = networkRecords.map((record) => record as Record<string, unknown>).filter((record) => record.type === "requestfailed")
575584
const classifiedFailures = failures.map((record): BrowserAdaptiveNetworkFailure & { expectedBlock: boolean } => {
576585
const url = typeof record.url === "string" ? record.url : ""
@@ -584,15 +593,15 @@ function adaptiveOracleEvidence(consoleRecords: Record<string, unknown>[], netwo
584593
const message = recordMessage(record)
585594
const locationUrl = objectRecord(record.location).url
586595
const match = classifiedFailures.findIndex((failure, index) => unmatchedFailures.has(index)
587-
&& (typeof locationUrl === "string" && locationUrl ? failure.url === locationUrl : failureTokenMatches(message, failure.failure)))
596+
&& (typeof locationUrl === "string" && locationUrl ? failure.url === locationUrl && failureTokenMatches(message, failure.failure) : failureTokenMatches(message, failure.failure)))
588597
if (match < 0) return [message]
589598
unmatchedFailures.delete(match)
590599
const failure = classifiedFailures[match]!
591600
return failure.expectedBlock && contract.oraclePolicy.policyBlocks === "evidence" ? [] : [message]
592601
})
593602
const networkMessages = classifiedFailures.filter((failure, index) => unmatchedFailures.has(index) && failure.oracleFinding).map(networkFailureOracleMessage)
594603
const messages = [...consoleMessages, ...pageErrors, ...networkMessages]
595-
const networkFailureSummary = classifiedFailures.length > 0 ? boundedNetworkFailureEvidence(classifiedFailures, contract) : undefined
604+
const networkFailureSummary = classifiedFailures.length > 0 ? boundedNetworkFailureEvidence(classifiedFailures, contract, retention) : undefined
596605
return {
597606
fingerprints: [...new Set(messages.map((message) => browserAdaptiveDigest("oracle", message)))].sort(),
598607
networkFailures: networkFailureSummary?.failures ?? [],
@@ -601,14 +610,19 @@ function adaptiveOracleEvidence(consoleRecords: Record<string, unknown>[], netwo
601610
}
602611
}
603612

604-
function boundedNetworkFailureEvidence(failures: Array<BrowserAdaptiveNetworkFailure & { expectedBlock: boolean }>, contract: BrowserAdaptiveExplorationContract): { failures: BrowserAdaptiveNetworkFailure[]; summary: NonNullable<BrowserAdaptiveTransition["observations"]["networkFailureSummary"]> } {
613+
function boundedNetworkFailureEvidence(failures: Array<BrowserAdaptiveNetworkFailure & { expectedBlock: boolean }>, contract: BrowserAdaptiveExplorationContract, cumulativeRetention?: AdaptiveNetworkFailureRetention): { failures: BrowserAdaptiveNetworkFailure[]; summary: NonNullable<BrowserAdaptiveTransition["observations"]["networkFailureSummary"]> } {
605614
const ordered = failures.map(({ expectedBlock: _expectedBlock, ...failure }) => failure).sort((left, right) => stableJson(left).localeCompare(stableJson(right)))
606-
const maximum = Math.min(contract.budgets.maxErrors, contract.descriptorLimits.maxDiagnostics)
607-
const byteBudget = Math.floor(contract.budgets.maxArtifactBytes / 8)
615+
const retention = cumulativeRetention ?? {
616+
remainingCount: Math.min(contract.budgets.maxErrors, contract.descriptorLimits.maxDiagnostics),
617+
remainingBytes: Math.floor(contract.budgets.maxArtifactBytes / 8),
618+
}
608619
const retained: BrowserAdaptiveNetworkFailure[] = []
609-
for (const failure of ordered.slice(0, maximum)) {
610-
if (Buffer.byteLength(stableJson([...retained, failure])) > byteBudget) break
620+
for (const failure of ordered.slice(0, retention.remainingCount)) {
621+
const bytes = Buffer.byteLength(stableJson(failure)) + 1
622+
if (bytes > retention.remainingBytes) break
611623
retained.push(failure)
624+
retention.remainingCount -= 1
625+
retention.remainingBytes -= bytes
612626
}
613627
return {
614628
failures: retained,

tests/browser-adaptive-exploration.test.ts

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,23 @@ test("URL-less policy-block console records correlate before promotion and prese
529529
assert.equal(mixed.result.transitions[0]?.observations.networkFailureSummary?.oracleFindings, 1)
530530
})
531531

532+
test("same-URL product errors require a matching failure token before policy correlation", async () => {
533+
const productMessage = "same-URL product defect"
534+
const productFingerprint = browserAdaptiveDigest("oracle", productMessage)
535+
const evidence = await runNetworkOracleFixture("block", "blocked", {}, false, true)
536+
assert.deepEqual(evidence.result.transitions[0]?.observations.oracleFingerprints, [productFingerprint])
537+
assert.equal(evidence.result.findings[0]?.fingerprint, productFingerprint)
538+
539+
const finding = await runNetworkOracleFixture("block", "blocked", { oraclePolicy: { policyBlocks: "finding" } }, false, true)
540+
const fingerprints = finding.result.transitions[0]?.observations.oracleFingerprints ?? []
541+
const blockMessage = finding.result.transitions[0]?.observations.consoleErrors.find((message) => message.includes("ERR_BLOCKED_BY_CLIENT"))
542+
assert(blockMessage)
543+
assert.equal(fingerprints.length, 2)
544+
assert(fingerprints.includes(productFingerprint))
545+
assert(fingerprints.includes(browserAdaptiveDigest("oracle", blockMessage)))
546+
assert.equal(finding.result.transitions[0]?.observations.networkFailureSummary?.policyBlocks, 1)
547+
})
548+
532549
test("allow and record policies do not explain unexpected same-origin request failures", async () => {
533550
for (const mode of ["allow", "record"] as const) {
534551
const run = await runNetworkOracleFixture(mode, "unexpected")
@@ -562,16 +579,20 @@ test("page exceptions remain findings alongside policy-aware network classificat
562579
test("policy-block floods retain bounded deterministic evidence without stopping later findings", async () => {
563580
const [first, second] = await runPolicyBlockFloodFixture()
564581
for (const result of [first, second]) {
565-
const flood = result.transitions.find((transition) => transition.observations.networkFailureSummary?.total === 80)
566-
assert(flood)
567-
assert.deepEqual(flood.observations.networkFailureSummary, { total: 80, retained: 2, policyBlocks: 80, oracleFindings: 0, truncated: true })
568-
assert.equal(flood.observations.networkFailures?.length, 2)
569-
assert.deepEqual(flood.observations.oracleFingerprints, [])
582+
const floods = result.transitions.filter((transition) => transition.observations.networkFailureSummary?.total === 80)
583+
assert.equal(floods.length, 3)
584+
assert.equal(floods.reduce((total, transition) => total + (transition.observations.networkFailures?.length ?? 0), 0), 2, "retained failures are bounded across the artifact")
585+
assert.deepEqual(floods.map((transition) => transition.observations.networkFailureSummary), [
586+
{ total: 80, retained: 2, policyBlocks: 80, oracleFindings: 0, truncated: true },
587+
{ total: 80, retained: 0, policyBlocks: 80, oracleFindings: 0, truncated: true },
588+
{ total: 80, retained: 0, policyBlocks: 80, oracleFindings: 0, truncated: true },
589+
])
590+
assert(floods.every((transition) => transition.observations.oracleFingerprints.length === 0))
570591
assert.equal(result.status, "findings")
571592
assert.equal(result.summary.errors, 1, "policy blocks must not consume the product error budget")
572593
assert.notEqual(result.summary.budgetExhausted, "maxArtifactBytes")
573594
assert.equal(result.findings.length, 1)
574-
assert.equal(result.findings[0]?.originalPath.length, 2)
595+
assert.equal(result.findings[0]?.originalPath.length, 4)
575596
assert.deepEqual(result.findings[0]?.minimizedPath, result.findings[0]?.originalPath)
576597
assert.deepEqual(result.findings[0]?.replay.actions, result.findings[0]?.minimizedPath)
577598
}
@@ -699,7 +720,7 @@ async function runRoutedFixture(topology: ReturnType<typeof browserPreviewTopolo
699720
}
700721
}
701722

702-
async function runNetworkOracleFixture(mode: "allow" | "block" | "record", behavior: "blocked" | "unexpected" | "mixed" | "exception", input: Record<string, unknown> = {}, urlLessConsole = false) {
723+
async function runNetworkOracleFixture(mode: "allow" | "block" | "record", behavior: "blocked" | "unexpected" | "mixed" | "exception", input: Record<string, unknown> = {}, urlLessConsole = false, sameUrlProductError = false) {
703724
const server = createServer((request, response) => {
704725
const path = new URL(request.url ?? "/", "http://preview.invalid").pathname
705726
if (path === "/failed.png") {
@@ -725,8 +746,12 @@ async function runNetworkOracleFixture(mode: "allow" | "block" | "record", behav
725746
const consoleMessages: Record<string, unknown>[] = []
726747
const errors: Record<string, unknown>[] = []
727748
const network: Record<string, unknown>[] = []
728-
attachBrowserCaptureListeners({ captureConsole: !urlLessConsole, captureErrors: true, captureNetwork: true, captureWebSocket: false, consoleMessages, errors, network, page })
729-
if (urlLessConsole) page.on("console", (message) => consoleMessages.push({ type: message.type(), text: message.text() }))
749+
attachBrowserCaptureListeners({ captureConsole: !urlLessConsole && !sameUrlProductError, captureErrors: true, captureNetwork: true, captureWebSocket: false, consoleMessages, errors, network, page })
750+
if (urlLessConsole || sameUrlProductError) page.on("console", (message) => {
751+
const location = message.location()
752+
if (sameUrlProductError && message.text().includes("ERR_BLOCKED_BY_CLIENT")) consoleMessages.push({ type: "error", text: "same-URL product defect", location })
753+
consoleMessages.push({ type: message.type(), text: message.text(), ...(urlLessConsole ? {} : { location }) })
754+
})
730755
await page.addInitScript("globalThis.__name = value => value")
731756
await page.goto(startUrl, { waitUntil: "load" })
732757
const contract = browserAdaptiveExplorationContract({
@@ -753,11 +778,16 @@ async function runPolicyBlockFloodFixture() {
753778
response.setHeader("content-type", "text/html")
754779
response.end(`<!doctype html><style>button{display:block;width:180px;height:30px}</style><button id="trigger">Load assets</button><script>
755780
document.querySelector('#trigger').addEventListener('click', () => {
781+
const stage = Number(document.querySelector('#trigger').dataset.stage || '0') + 1;
782+
document.querySelector('#trigger').dataset.stage = String(stage);
783+
document.querySelector('#trigger').textContent = 'Load assets stage ' + String(stage);
756784
for (let index = 0; index < 80; index += 1) {
757785
const image = document.createElement('img');
758-
image.src = 'http://assets.example.invalid/tile-' + String(index).padStart(3, '0') + '.png';
786+
image.src = 'http://assets.example.invalid/tile-' + String(stage) + '-' + String(index).padStart(3, '0') + '.png';
759787
document.body.append(image);
760788
}
789+
if (stage < 3) return;
790+
document.querySelector('#trigger').disabled = true;
761791
const fail = document.createElement('button');
762792
fail.id = 'fail';
763793
fail.textContent = 'Reveal defect';
@@ -784,7 +814,7 @@ async function runPolicyBlockFloodFixture() {
784814
startUrl,
785815
failOnFinding: false,
786816
actionFamilies: ["click"],
787-
budgets: { maxActions: 20, maxStates: 6, maxTransitions: 10, maxDurationMs: 20_000, maxArtifactBytes: 40_000, maxErrors: 2 },
817+
budgets: { maxActions: 40, maxStates: 8, maxTransitions: 20, maxDurationMs: 30_000, maxArtifactBytes: 40_000, maxErrors: 2 },
788818
descriptorLimits: { maxPerState: 10, maxDiagnostics: 3, maxTextLength: 500 },
789819
stabilization: { pollIntervalMs: 25, quietWindowMs: 100, maxWaitMs: 1_500, maxMutationRecords: 100 },
790820
})

0 commit comments

Comments
 (0)