diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index 540f0064d..d3cbe5d5a 100644 --- a/src/session/compaction-verify.test.ts +++ b/src/session/compaction-verify.test.ts @@ -14,6 +14,7 @@ import { repairSummary, verifyCompactionSummary, verifyOrRepair, + verifyAbortNoticeLabel, VERIFY_REPAIR_HEADING, } from "./compaction-verify.js"; import type { @@ -421,7 +422,7 @@ describe("verifyCompactionSummary", () => { } }); - test("uppercase HTTP URL scores hostname case-insensitively", () => { + test("a URL path is required; hostname alone does not cover it", () => { const facts = extractContinuationFacts([ textTurn("user", "Fix auth now"), { @@ -442,6 +443,12 @@ describe("verifyCompactionSummary", () => { "Fix auth now. Called api.com.", facts, ).misses.some((m) => m.kind === "exactName"), + ).toBe(true); + expect( + verifyCompactionSummary( + "Fix auth now. Called API.COM/v1.", + facts, + ).misses.some((m) => m.kind === "exactName"), ).toBe(false); expect( verifyCompactionSummary( @@ -453,6 +460,12 @@ describe("verifyCompactionSummary", () => { }); describe("verifyOrRepair", () => { + test("names contradiction and repair overflow instead of a bare verify", () => { + expect(verifyAbortNoticeLabel("contradiction")).toBe("contradiction"); + expect(verifyAbortNoticeLabel("repair-overflow")).toBe("repair overflow"); + expect(verifyAbortNoticeLabel(undefined)).toBe("kept-prior"); + }); + test("repairs a lossy handoff instead of shipping it", () => { const facts = extractContinuationFacts(droppedTurns()); const outcome = verifyOrRepair( @@ -477,6 +490,7 @@ describe("verifyOrRepair", () => { ); expect(outcome.aborted).toBe(true); expect(outcome.repaired).toBe(false); + expect(outcome.abortReason).toBe("contradiction"); }); test("truncating repair that still misses exactName aborts", () => { @@ -495,9 +509,10 @@ describe("verifyOrRepair", () => { timestamp: 2, }, ]); - const outcome = verifyOrRepair("Fix auth now. Work continues.", facts, 90); + const outcome = verifyOrRepair("Fix auth now. Work continues.", facts, 80); expect(outcome.aborted).toBe(true); expect(outcome.repaired).toBe(false); + expect(outcome.abortReason).toBe("repair-overflow"); expect(outcome.misses.some((m) => m.kind === "exactName")).toBe(true); }); @@ -527,6 +542,26 @@ describe("verifyOrRepair", () => { expect(outcome.summary).toContain("exact-file.ts"); }); + test("shrinks model prose so a carry-forward that fits the cap still verifies", () => { + const facts = extractContinuationFacts(droppedTurns()); + const prose = "Padded model prose. ".repeat(40); + const misses = verifyCompactionSummary(prose, facts).misses; + const full = repairSummary(prose, facts, misses); + const appendixAt = full.indexOf(VERIFY_REPAIR_HEADING); + expect(appendixAt).toBeGreaterThan(200); + const appendix = full.slice(appendixAt).replace(/^\n+/, ""); + const cap = appendix.length + 8; + expect(full.length).toBeGreaterThan(cap); + const outcome = verifyOrRepair(prose, facts, cap); + expect(outcome.aborted).toBe(false); + expect(outcome.repaired).toBe(true); + expect(outcome.summary.length).toBeLessThanOrEqual(cap); + expect(outcome.summary).toContain(VERIFY_REPAIR_HEADING); + expect(verifyCompactionSummary(outcome.summary, facts).supported).toBe( + true, + ); + }); + test("a constraint-only residual after a sliced repair aborts", () => { const facts = extractContinuationFacts([ textTurn("user", "Fix auth now"), @@ -662,10 +697,200 @@ describe("pruning compactor verify pass", () => { expect(allText(result.output)).toContain("auth.ts"); } else if (kind === "aborted") { expect(result.output).toBe(turns); + expect(result.record.decisions.verifyAbortReason).toBe("contradiction"); } else { expect(allText(result.output)).not.toContain(VERIFY_REPAIR_HEADING); } }); + + test("an over-cap repair commits the fold and the handoff keeps critical facts", async () => { + const folded: ConversationTurn[] = [ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: "src/very-long-unique-path/exact-file.ts" }, + }, + ], + timestamp: 2, + }, + { + role: "user", + content: [ + { + type: "tool_result", + callId: "c1", + isError: true, + content: [ + { + type: "text", + text: "ZEBRA-BLOCKER-UNIQUE token refresh exploded", + }, + ], + }, + ], + timestamp: 3, + }, + textTurn( + "assistant", + "Next step is to patch the zebra handler QUOKKA-NEXT", + ), + textTurn("user", "Do not commit generated artifacts ever."), + ]; + const turns: ConversationTurn[] = [ + ...folded, + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + const compactor = createPruningCompactor({ + summaryMaxChars: 80, + compactionShape: { tailBudgetTokens: 10 }, + summarize: async () => "Work continues.", + }); + const result = await compactor.apply(turns, mockStrategyCtx); + expect(result.record.decisions.verifyAborted).toBeUndefined(); + expect(result.record.reason.startsWith("compacted")).toBe(true); + expect(result.output.length).toBeLessThan(turns.length); + const file = handoffFileText(result); + expect(file).toContain("exact-file.ts"); + expect(file).toContain("ZEBRA-BLOCKER-UNIQUE"); + expect(file).toContain("QUOKKA-NEXT"); + expect(file).toContain("generated artifacts"); + expect(file.toLowerCase()).toContain("fix auth"); + const spine = allText(result.output); + expect(spine).toContain(COMPACTED_PREFIX); + expect(spine).not.toMatch(/\bno\s+errors?\b/i); + }); + + test("shrink-to-fit does not ship a spine that denies recorded failures", async () => { + const folded: ConversationTurn[] = [ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: "src/auth.ts" }, + }, + ], + timestamp: 2, + }, + { + role: "user", + content: [ + { + type: "tool_result", + callId: "c1", + isError: true, + content: [{ type: "text", text: "token refresh assertion failed" }], + }, + ], + timestamp: 3, + }, + textTurn("assistant", "Fix the token refresh assertion next."), + textTurn("user", "No errors remain"), + textTurn("user", "Keep going on the handler"), + ]; + const turns: ConversationTurn[] = [ + ...folded, + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + const prose = "Padded model prose. ".repeat(40); + const facts = extractContinuationFacts(folded); + const misses = verifyCompactionSummary(prose, facts).misses; + const full = repairSummary(prose, facts, misses); + const appendixAt = full.indexOf(VERIFY_REPAIR_HEADING); + const appendix = full.slice(appendixAt).replace(/^\n+/, ""); + const cap = appendix.length + 8; + expect(full.length).toBeGreaterThan(cap); + const shrunk = verifyOrRepair(prose, facts, cap); + expect(shrunk.aborted).toBe(false); + expect(shrunk.summary.length).toBeLessThanOrEqual(cap); + + const apply = (summaryMaxChars: number) => + createPruningCompactor({ + summaryMaxChars, + compactionShape: { tailBudgetTokens: 10 }, + summarize: async () => prose, + }).apply(turns, mockStrategyCtx); + + const fitted = await apply(cap); + const fittedSpine = allText(fitted.output); + const committed = fitted.output.length < turns.length; + if (committed) { + expect(fittedSpine).not.toMatch(/\bno\s+errors?\s+remain\b/i); + } else { + expect(fitted.output).toBe(turns); + expect(fitted.record.decisions.verifyAbortReason).toBe("contradiction"); + } + expect(committed && /\bno\s+errors?\s+remain\b/i.test(fittedSpine)).toBe( + false, + ); + + const tight = await apply(80); + expect(tight.output).toBe(turns); + expect(tight.record.decisions.verifyAbortReason).toBe("contradiction"); + }); + + test("an over-cap fold does not commit without the exact URL path", async () => { + const url = "https://api.example/hooks/QUOKKA-CALLBACK"; + const folded: ConversationTurn[] = [ + textTurn("user", "Register the callback host api.example"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "web_fetch", + arguments: { url }, + }, + ], + timestamp: 2, + }, + textTurn("assistant", "Next step is to patch the handler QUOKKA-NEXT"), + textTurn("user", "Do not commit generated artifacts ever."), + ]; + const turns: ConversationTurn[] = [ + ...folded, + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + const prose = "Work continues."; + const facts = extractContinuationFacts(folded); + expect(facts.exactNames).toContain(url); + const misses = verifyCompactionSummary(prose, facts).misses; + const full = repairSummary(prose, facts, misses); + const exactAt = full.indexOf("Exact references:"); + expect(exactAt).toBeGreaterThan(0); + expect(full.slice(0, exactAt)).toContain("api.example"); + expect(full.slice(0, exactAt)).not.toContain("QUOKKA-CALLBACK"); + + const result = await createPruningCompactor({ + summaryMaxChars: exactAt, + compactionShape: { tailBudgetTokens: 10 }, + summarize: async () => prose, + }).apply(turns, mockStrategyCtx); + + const file = handoffFileText(result); + const spine = allText(result.output); + const keptPrior = result.output === turns; + const pathKept = + file.includes("hooks/QUOKKA-CALLBACK") || + spine.includes("hooks/QUOKKA-CALLBACK"); + expect(keptPrior || pathKept).toBe(true); + if (!keptPrior) { + expect(result.record.decisions.verifyAborted).toBeUndefined(); + expect(result.record.reason.startsWith("compacted")).toBe(true); + } + }); }); describe("CL-9007 budgeted tail (shared auto+manual pipeline)", () => { diff --git a/src/session/compaction-verify.ts b/src/session/compaction-verify.ts index 5407dd011..5af5d0b2a 100644 --- a/src/session/compaction-verify.ts +++ b/src/session/compaction-verify.ts @@ -47,9 +47,22 @@ export interface VerifyOutcome { summary: string; repaired: boolean; aborted: boolean; + /** Set only when the fold must keep prior context. */ + abortReason?: VerifyAbortReason; misses: VerifyMiss[]; } +export type VerifyAbortReason = "contradiction" | "repair-overflow"; + +/** Operator-facing cause for a verify keep-prior. Never the bare word "verify". */ +export function verifyAbortNoticeLabel( + abortReason: VerifyAbortReason | undefined, +): string { + if (abortReason === "contradiction") return "contradiction"; + if (abortReason === "repair-overflow") return "repair overflow"; + return "kept-prior"; +} + export const VERIFY_REPAIR_HEADING = "## Carry-forward (verify repair)"; // Words with no identifying signal. Kept small: every extra stopword is a @@ -321,17 +334,33 @@ export function extractContinuationFacts( } // Basename for paths (a summary moving `src/auth.ts` to "auth.ts" still -// names it); hostname for URLs (query strings get reworded freely). Match +// names it). A URL with a path needs that path, not the hostname alone: +// `api.example` does not cover `https://api.example/hooks/id`. Query strings +// may be dropped. A URL with no path still matches on the hostname. Match // on token/path boundaries so "oauth.ts" does not cover "auth.ts" and // "www.api.com" does not cover hostname "api.com". +function urlPathBody(url: string): string | undefined { + try { + const path = new URL(url).pathname.replace(/\/+$/, ""); + if (path.length <= 1) return undefined; + return path.slice(1).toLowerCase(); + } catch { + return undefined; + } +} + function exactNameSupported(name: string, summary: string): boolean { const lowered = summary.toLowerCase(); const loweredName = name.toLowerCase(); if (loweredName.startsWith("http://") || loweredName.startsWith("https://")) { + if (tokenAppears(loweredName, lowered)) return true; const host = hostnameOf(name); - if (host !== undefined && tokenAppears(host.toLowerCase(), lowered)) - return true; - return tokenAppears(loweredName, lowered); + if (host === undefined) return false; + const path = urlPathBody(name); + if (path === undefined) return tokenAppears(host.toLowerCase(), lowered); + return ( + tokenAppears(host.toLowerCase(), lowered) && tokenAppears(path, lowered) + ); } const base = name.split("/").pop() ?? name; return base.length > 0 && tokenAppears(base.toLowerCase(), lowered); @@ -391,6 +420,46 @@ export function verifyCompactionSummary( return { supported: misses.length === 0, misses }; } +/** Facts are present even if the text also denies failure. */ +export function continuationTextCarriesFacts( + text: string, + facts: ContinuationFacts, +): boolean { + return verifyCompactionSummary(text, facts).misses.every( + (miss) => miss.kind === "contradiction", + ); +} + +/** Spine (or summary) explicitly denies failures the dropped turns recorded. */ +export function textDeniesRecordedFailures( + text: string, + facts: ContinuationFacts, +): boolean { + return facts.blockers.length > 0 && DENIES_FAILURE.test(text); +} + +/** + * Keep the carry-forward appendix inside the cap by shrinking model prose + * first. A naive head slice drops the critical prefix, which is what made + * every over-cap repair abort. + */ +function fitRepairWithinCap(repairedFull: string, maxChars: number): string { + if (maxChars <= 0) return ""; + if (repairedFull.length <= maxChars) return repairedFull; + const at = repairedFull.indexOf(VERIFY_REPAIR_HEADING); + if (at < 0) return repairedFull.slice(0, maxChars); + let proseEnd = at; + while (proseEnd > 0 && repairedFull[proseEnd - 1] === "\n") proseEnd -= 1; + const prose = repairedFull.slice(0, proseEnd); + const appendix = repairedFull.slice(proseEnd).replace(/^\n+/, ""); + if (appendix.length >= maxChars) return appendix.slice(0, maxChars); + const room = maxChars - appendix.length - 2; + if (room <= 0) return appendix; + const kept = prose.slice(0, room).trimEnd(); + if (kept.length === 0) return appendix; + return `${kept}\n\n${appendix}`; +} + /** * Deterministic rewrite: append missing facts verbatim under a repair * heading so the next agent resumes with names and wording intact. @@ -436,10 +505,12 @@ export function repairSummary( } /** - * Verify a candidate handoff, repairing once or aborting the fold. A - * contradiction aborts outright: an appended correction cannot retract the - * handoff's false denial. A truncating repair that still misses any fact - * aborts too — never ship a lying spine. + * Verify a candidate summary, repairing once. A contradiction aborts + * outright: an appended correction cannot retract a false denial. When the + * repair exceeds the cap, model prose shrinks so the critical prefix is + * kept. Residual misses are repair-overflow — the compactor still commits + * when the handoff file carries those facts, and aborts only when it does + * not. Never treat a truncated repair as a faithful spine. */ export function verifyOrRepair( summary: string, @@ -450,16 +521,33 @@ export function verifyOrRepair( if (first.supported) return { summary, repaired: false, aborted: false, misses: [] }; if (first.misses.some((m) => m.kind === "contradiction")) - return { summary, repaired: false, aborted: true, misses: first.misses }; + return { + summary, + repaired: false, + aborted: true, + abortReason: "contradiction", + misses: first.misses, + }; const repairedFull = repairSummary(summary, facts, first.misses); - const repaired = - repairedFull.length > maxChars - ? repairedFull.slice(0, maxChars) - : repairedFull; + const repaired = fitRepairWithinCap(repairedFull, maxChars); const second = verifyCompactionSummary(repaired, facts); + if (second.misses.some((m) => m.kind === "contradiction")) + return { + summary, + repaired: false, + aborted: true, + abortReason: "contradiction", + misses: second.misses, + }; if (second.misses.length > 0) - return { summary, repaired: false, aborted: true, misses: second.misses }; + return { + summary: repaired, + repaired: false, + aborted: true, + abortReason: "repair-overflow", + misses: second.misses, + }; return { summary: repaired, repaired: true, diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 01865ff98..3ce3f5d61 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -20,8 +20,13 @@ import { type SummaryContext, } from "./summarizer.js"; import { + continuationTextCarriesFacts, extractContinuationFacts, + repairSummary, + textDeniesRecordedFailures, verifyOrRepair, + type VerifyAbortReason, + type VerifyMiss, } from "./compaction-verify.js"; import { canonicalToolName } from "../agent/canonical-tool-name.js"; import { @@ -1066,59 +1071,101 @@ export function createPruningCompactor( // Verify pass: the handoff must still carry the dropped turns' // continuation facts (goal, next action, exact names, blockers). Lossy - // summaries repair deterministically; contradicting ones abort the fold. - const verified = verifyOrRepair( - summary, - extractContinuationFacts(summarizedTurns), - cfg.summaryMaxChars, - ); - if (verified.aborted) { - return { - output: turns, - record: { - strategy: this.name, - version: this.version, - parameters: { - compactionShape: shape, - }, - reason: "verify failed — keeping prior context", - decisions: { - verifyAborted: 1, - verifyMissing: verified.misses.map((m) => m.kind), - agedImageCount: aged.agedImageCount, - }, + // summaries repair deterministically. A contradiction aborts the fold. + // A repair that does not fit the summary cap still commits when the + // handoff file carries the overflow — the live spine stays the thin + // extract, never a denial of recorded failures. Shrink-to-fit is the + // same rule: a fitting appendix does not excuse a lying spine. + const facts = extractContinuationFacts(summarizedTurns); + const verified = verifyOrRepair(summary, facts, cfg.summaryMaxChars); + const keepPrior = ( + abortReason: VerifyAbortReason, + misses: readonly VerifyMiss[], + ) => ({ + output: turns, + record: { + strategy: this.name, + version: this.version, + parameters: { + compactionShape: shape, }, - }; + reason: "verify failed — keeping prior context", + decisions: { + verifyAborted: 1, + verifyAbortReason: abortReason, + verifyMissing: misses.map((m) => m.kind), + agedImageCount: aged.agedImageCount, + }, + }, + }); + if (verified.aborted && verified.abortReason === "contradiction") { + return keepPrior("contradiction", verified.misses); + } + + const priorFileText = await cfg.readPriorHandoff?.(); + const handoffOpts = { + ...(priorFileText !== undefined ? { priorFileText } : {}), + ...(summaryCtx?.activatedTools !== undefined + ? { activatedTools: summaryCtx.activatedTools } + : {}), + }; + const handoffCarries = (fileBytes: Uint8Array, spineText: string) => + continuationTextCarriesFacts( + `${new TextDecoder().decode(fileBytes)}\n${spineText}`, + facts, + ); + + let narrative = verified.summary; + let handoff = buildHandoffFold(summarizedTurns, narrative, handoffOpts); + let overflowFold = false; + const spineDeniesFailures = (): boolean => + textDeniesRecordedFailures(handoff.spineText, facts); + const spineContradiction = () => + keepPrior("contradiction", [ + ...verified.misses, + { + kind: "contradiction", + detail: "spine denies failure while dropped turns record errors", + }, + ]); + // Shrink-to-fit (and any other non-abort) still builds Decisions from + // folded user text. That line can deny failures the summary never said. + if (!verified.aborted && spineDeniesFailures()) { + return spineContradiction(); + } + if (verified.aborted) { + if (spineDeniesFailures()) return spineContradiction(); + if (!handoffCarries(handoff.blob.bytes, handoff.spineText)) { + narrative = repairSummary(narrative, facts, verified.misses); + handoff = buildHandoffFold(summarizedTurns, narrative, handoffOpts); + } + if ( + spineDeniesFailures() || + !handoffCarries(handoff.blob.bytes, handoff.spineText) + ) { + return keepPrior("repair-overflow", verified.misses); + } + overflowFold = true; } - summary = verified.summary; + summary = narrative; const verifyDecisions = - verified.repaired && verified.misses.length > 0 + overflowFold || (verified.repaired && verified.misses.length > 0) ? { verifyRepaired: 1, verifyMissing: verified.misses.map((m) => m.kind), } : {}; - // The summary is framed as user content: a user-role turn survives every - // adapter unchanged, a system-role turn does not (Anthropic drops - // mid-conversation system turns under a system-prompt override; Grok - // emits them as a stray mid-stream system message). Activated tools - // outlive the fold, so the handoff states them verbatim. - // // The fold writes a fat structured handoff file (goal, constraints, // decisions, evidence markers, files/commands, verification, dead ends, // next actions, verbatim exact-facts appendix) under one stable latest // key, keeping only a thin spine plus a pointer to that file in the // live prompt. The spine unions carried facts with new ones; dropped // prior spines join the evidence archive so the completeness gate - // still certifies the fold. - const priorFileText = await cfg.readPriorHandoff?.(); - const handoff = buildHandoffFold(summarizedTurns, summary, { - ...(priorFileText !== undefined ? { priorFileText } : {}), - ...(summaryCtx?.activatedTools !== undefined - ? { activatedTools: summaryCtx.activatedTools } - : {}), - }); + // still certifies the fold. `handoff` was built above from the verified + // narrative (overflow facts live in the file, not a second copy). + // The spine is user-role so every adapter keeps it: Anthropic drops + // mid-conversation system turns, and Grok emits them mid-stream. const summaryTurn: ConversationTurn = { role: "user", content: [{ type: "text", text: handoff.spineText }], diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index 3cd888eeb..7106ede80 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -668,12 +668,12 @@ describe("createSessionPruningCompactor stub fallback", () => { }); test.each([ - ["model unreachable", "verify"], + ["model unreachable", undefined], ["401 Unauthorized", "auth"], ["429 quota exceeded", "quota"], ])( "verify abort after summarizer failure (%s) keeps prior context and notifies", - async (errorMessage, reason) => { + async (errorMessage, failureKind) => { const notices: string[] = []; const kept: number[] = []; const folds: { stub: boolean }[] = []; @@ -792,8 +792,14 @@ describe("createSessionPruningCompactor stub fallback", () => { expect(kept).toEqual([1]); expect(notices).toHaveLength(1); expect(notices[0]?.toLowerCase()).toContain("fail"); - expect(notices[0]?.toLowerCase()).toContain("verify"); - expect(notices[0]?.toLowerCase()).toContain(reason); + expect(notices[0]?.toLowerCase()).toContain("contradiction"); + expect(notices[0]).not.toMatch(/\(verify\)/); + if (failureKind !== undefined) { + expect(notices[0]?.toLowerCase()).toContain(failureKind); + expect(notices[0]?.indexOf(failureKind) ?? -1).toBeLessThan( + notices[0]?.indexOf("contradiction") ?? -1, + ); + } expect(notices[0]).not.toContain("statistics-only stub"); expect(notices[0]).not.toContain("Compaction interrupted"); }, diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index e31631a8f..97d1762f0 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -56,6 +56,7 @@ import { createPruningCompactor, type CompactionShape, } from "./compactor.js"; +import { verifyAbortNoticeLabel } from "./compaction-verify.js"; import { classifySummarizerFailure, summarizerStubFallbackNotice, @@ -479,9 +480,16 @@ export interface SessionPruningCompactorArgs { function compactionFailedKeepNotice( kind: SummarizerFailureClass | undefined, + abortReason: unknown, ): string { - const reason = - kind === "auth" || kind === "quota" ? `${kind}, verify` : "verify"; + const why = verifyAbortNoticeLabel( + abortReason === "contradiction" || abortReason === "repair-overflow" + ? abortReason + : undefined, + ); + // Auth and quota stay first: the operator needs the credential failure + // even when verify also refused the fold. + const reason = kind === "auth" || kind === "quota" ? `${kind}, ${why}` : why; return `Compaction failed — keeping prior context (${reason}).`; } @@ -569,7 +577,12 @@ export function createSessionPruningCompactor( // interrupted notice. if (result.record.decisions.verifyAborted === 1) { args.onKeptPrior?.(); - args.onFailure?.(compactionFailedKeepNotice(pendingFailureKind)); + args.onFailure?.( + compactionFailedKeepNotice( + pendingFailureKind, + result.record.decisions.verifyAbortReason, + ), + ); return result; } if (