diff --git a/apps/server/src/conversation-plan/interpret.test-fixtures.ts b/apps/server/src/conversation-plan/interpret.test-fixtures.ts new file mode 100644 index 00000000..c59984fa --- /dev/null +++ b/apps/server/src/conversation-plan/interpret.test-fixtures.ts @@ -0,0 +1,141 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import type { JevQuestion } from "./jev"; +import { optionIdFor } from "./policy"; +import { message } from "./policy-initial.test-fixtures"; +export { message } from "./policy-initial.test-fixtures"; +export { stateWithOption } from "./policy-terminal.test-fixtures"; + +// Exact archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, pipeline.test.ts. +// Complete callbacks and helpers retained; injected offline transport only. +export let seededOptionId = optionIdFor("channel", "seed-option", 0, 1_000); + +export function seeded(): ConversationPlan.State { + let state = initialState(); + let original = message("m1", "Should we use an optional outline?"); + return applyInference(state, { + id: "open", + type: "thread.opened", + threadId: "thread-a", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: original.id, + author: original.author as ConversationPlan.SourceAuthor, + quote: original.text, + start: 0, + end: original.text.length, + role: "question", + }, + question: original.text, + }, original); +} + +export function withOption(state: ConversationPlan.State): ConversationPlan.State { + let proposal = message("seed-option", "Use an optional outline."); + return applyInference(state, { + id: "seed-option-event", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0].version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: proposal.id, + author: proposal.author as ConversationPlan.SourceAuthor, + quote: proposal.text, + start: 0, + end: proposal.text.length, + role: "option", + }, + contribution: { + id: seededOptionId, + text: proposal.text, + authoring: "quoted", + targetId: "thread-a", + }, + }, proposal); +} + +export function settledBy(state: ConversationPlan.State, handle: string): ConversationPlan.State { + let withFirstOption = withOption(state); + let proposal = message("settle-proposal", "Let's just go with it.", handle); + return applyInference(withFirstOption, { + id: "settle-proposal-event", + type: "settle.suggested", + threadId: "thread-a", + observedThreadVersion: withFirstOption.threads[0].version, + origin: "classifier", + actor: { kind: "classifier" }, + at: proposal.ts, + source: { + messageId: proposal.id, + author: proposal.author as ConversationPlan.SourceAuthor, + quote: proposal.text, + start: 0, + end: proposal.text.length, + role: "resolution", + }, + optionId: seededOptionId, + }, proposal); +} + +export function mockResult( + questions: Record, + overrides: Record, +): any { + let answers: Record = {}; + let distributions: Record> = {}; + for (let [key, question] of Object.entries(questions)) { + if (question.type === "noul") { + answers[key] = { + type: "noul", + noul: typeof overrides[key] === "number" + ? overrides[key] + : key.endsWith("_owned_unretracted") + ? 0.95 + : 0.05, + }; + } + if (question.type === "choice") { + let choice = typeof overrides[key] === "string" ? overrides[key] : "none"; + if (!(choice in question.criteria)) choice = Object.keys(question.criteria)[0]; + let probabilities = Object.fromEntries( + Object.keys(question.criteria).map(( + option, + ) => [ + option, + option === choice ? 0.95 : 0.05 / (Object.keys(question.criteria).length - 1), + ]), + ); + answers[key] = { type: "choice", choice, confidence: 0.95, probabilities }; + } + if (question.type === "score") { + let chosen = typeof overrides[key] === "number" ? Math.round(overrides[key]) : 0; + let probabilities = Object.fromEntries( + question.criteria.map((_, i) => [String(i), i === chosen ? 1 : 0]), + ); + answers[key] = { + type: "score", + score: chosen, + confidence: 1, + legend: Object.fromEntries(question.criteria.map((label, i) => [String(i), label])), + probabilities, + }; + } + let answer = answers[key]; + distributions[key] = answer.type === "noul" + ? { yes: answer.noul, no: 1 - answer.noul } + : answer.probabilities; + } + return { + model: "mock-jev", + answers, + distributions, + usage: { input_tokens: 1, output_tokens: 1 }, + latencyMs: 1, + }; +} diff --git a/apps/server/src/conversation-plan/pipeline-commitment-ownership.test.ts b/apps/server/src/conversation-plan/pipeline-commitment-ownership.test.ts new file mode 100644 index 00000000..33bada86 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-commitment-ownership.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; + +test("declarative option capture refuses attribution, negation, withdrawal, ambiguity, and repeats", () => { + let state = stateWithOption( + "How should we provide agent access?", + "access-thread", + "existing-access", + "Use a hosted gateway.", + ); + let duplicateState = stateWithOption( + "How should we provide agent access?", + "access-thread", + "existing-access", + "Copilot.", + ); + let plain = "By agent access I mean Copilot or bring-your-own API keys."; + let assess = ( + text: string, + overrides: { + first?: Record; + second?: Record; + triage?: Record; + } = {}, + candidateState = state, + ) => { + let current = message(`guard-${text}`, text); + return planEvents({ + channelId: "channel", + message: current, + state: candidateState, + first: { + ...first({ new_option: 0.96 }), + thread_target: confidentChoice("access-thread"), + ...overrides.triage, + }, + candidates: extractQuotes(text).map((quote, index) => ({ + ...quote, + answers: { + ...follow({ role: "none", thread: "access-thread" }), + new_option: { type: "noul", noul: 0.93 }, + duplicate: { type: "noul", noul: 0.04 }, + ...(index ? overrides.second : overrides.first), + }, + })), + }); + }; + for ( + let text of [ + "Bob said, by agent access I mean Copilot or bring-your-own API keys.", + "By agent access I mean not Copilot or bring-your-own API keys.", + `${plain} Actually, I take that back.`, + "By agent access I mean Copilot or Copilot.", + ] + ) { + expect(extractQuotes(text)).not.toEqual([ + { quote: "Copilot", start: 23, end: 30 }, + { quote: "bring-your-own API keys", start: 34, end: 57 }, + ]); + expect(assess(text).events).toEqual([]); + } + expect(assess(plain, {}, duplicateState).events).toEqual([]); + expect(assess(plain, { triage: { thread_target: confidentChoice("new") } }).events) + .toEqual([]); + expect( + assess(plain, { triage: { c1_owned_unretracted: { type: "noul", noul: 0.2 } } }) + .events, + ).toEqual([]); + expect(assess(plain, { second: { thread: confidentChoice("none") } }).events).toEqual([]); + expect( + assess(plain, { second: { duplicate: { type: "noul", noul: 0.95 } } }) + .events, + ).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-commitment-pending.test.ts b/apps/server/src/conversation-plan/pipeline-commitment-pending.test.ts new file mode 100644 index 00000000..9d7a4709 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-commitment-pending.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "bun:test"; +import type { JevQuestion } from "./jev"; +import { + buildCandidateTargetingRequest, + buildTargetingRequest, + buildTriageRequest, + QUESTION_SET_VERSION, +} from "./questions"; +import { extractQuotes } from "./quotes"; +import { seeded, settledBy } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; + +test("mixed-message assent names the unique pending choice in its own targeting questions", () => { + let current = message( + "mixed-assent", + "Sounds good to me. What about agent access? Copilot? BYO API keys?", + "alice", + ); + let prior = message("prior-proposal", "Let's just go with the optional outline.", "bob"); + let request = buildTargetingRequest( + current, + [prior], + settledBy(seeded(), "bob").threads, + extractQuotes(current.text), + ); + let state = request.state as { + recent: Array<{ text: string }>; + candidates: Array<{ quote: string }>; + }; + expect(state.recent.at(-1)?.text).toBe(prior.text); + expect(state.candidates.map(candidate => candidate.quote)).toEqual([ + "Sounds good to me.", + "What about agent access?", + "Copilot?", + "BYO API keys?", + ]); + let target = request.questions.c0_thread as Extract; + expect(target.instructions).toContain("candidates[0].quote"); + expect(target.instructions).toContain("later clauses"); + expect(target.criteria["thread-a"].toLowerCase()).toContain("pending proposal to settle"); + expect(target.criteria["thread-a"]).toContain("Use an optional outline."); + let agreement = request.questions.c0_agrees_with_settle; + expect(agreement?.instructions).toContain("candidates[0].quote"); + expect(agreement?.instructions).toContain("Use an optional outline."); + expect(agreement?.instructions).toContain("bob"); + expect(agreement?.instructions).toContain("later clauses"); + expect(agreement?.instructions).toContain("quoted"); + expect(agreement?.instructions).toContain("sarcasm"); +}); + +test("v5 triage checks exact quote ownership while targeting isolates each clause", () => { + let current = message( + "mixed-assent", + "Sounds good to me. What about agent access? Copilot? BYO API keys?", + "Jules", + ); + let recent = [ + message("purpose", "We need to figure out auth.", "Mina"), + message("alternatives", "Auth0, custom login, or GitHub auth?", "Jules"), + message("proposal", "Let's just go with GitHub.", "Mina"), + ]; + let quotes = extractQuotes(current.text); + let state = settledBy(seeded(), "Mina"); + let triage = buildTriageRequest(current, recent, state.threads, quotes); + expect(QUESTION_SET_VERSION).toBe("conversation-plan-8"); + expect(Object.keys(triage.questions).filter(key => key.endsWith("_owned_unretracted"))) + .toEqual([ + "c0_owned_unretracted", + "c1_owned_unretracted", + "c2_owned_unretracted", + "c3_owned_unretracted", + ]); + expect( + (triage.state as { candidates: Array<{ quote: string; start: number; end: number }> }) + .candidates, + ).toEqual(quotes); + for (let index = 0; index < quotes.length; index++) { + let request = buildCandidateTargetingRequest( + current, + recent, + state.threads, + quotes, + index, + ); + let visible = request.state as { + current: { id: string; text: string }; + preceding?: string; + recent: Array<{ id: string }>; + candidates: Array<{ quote: string; start: number; end: number }>; + }; + expect(Object.keys(request.questions).every(key => key.startsWith(`c${index}_`))) + .toBe(true); + expect(visible.current).toMatchObject({ id: current.id, text: quotes[index]!.quote }); + expect(visible.candidates.at(-1)).toEqual(quotes[index]); + expect(visible.candidates).toHaveLength(index + 1); + expect(visible.recent.map(entry => entry.id)).toEqual(recent.map(entry => entry.id)); + expect(visible.preceding ?? "").toBe( + current.text.slice(0, quotes[index]!.start) + .slice(-300), + ); + if (index < 3) expect(JSON.stringify(request.state)).not.toContain("BYO API keys?"); + } +}); diff --git a/apps/server/src/conversation-plan/pipeline-commitment-reason.test.ts b/apps/server/src/conversation-plan/pipeline-commitment-reason.test.ts new file mode 100644 index 00000000..ca297ca3 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-commitment-reason.test.ts @@ -0,0 +1,121 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; + +test("captures a causal reason despite a near tie with support, with a clear existing option", () => { + let state = stateWithOption( + "Which queue should handle background jobs?", + "queue-thread", + "sqs-option", + "Use SQS.", + ); + let current = message("sqs-reason", "SQS would reduce operations for us."); + let answers = { + ...follow({ role: "reason", thread: "queue-thread" }), + role: { + type: "choice" as const, + choice: "support", + confidence: 0.38, + probabilities: { reason: 0.39, support: 0.45, option: 0.13, resolution: 0.03 }, + }, + thread: { + type: "choice" as const, + choice: "queue-thread", + confidence: 0.99, + probabilities: { "queue-thread": 1, none: 0 }, + }, + option: { + type: "choice" as const, + choice: "sqs-option", + confidence: 1, + probabilities: { "sqs-option": 1, none: 0 }, + }, + relation: { + type: "choice" as const, + choice: "supports", + confidence: 0.96, + probabilities: { supports: 0.96, unrelated: 0.04 }, + }, + planning_substance: { type: "noul" as const, noul: 0.87 }, + support: { type: "noul" as const, noul: 0.86 }, + duplicate: { type: "noul" as const, noul: 0.12 }, + }; + let triage = { + ...first({ reason: 0.91, c0_owned_unretracted: 0.94 }), + thread_target: { + type: "choice" as const, + choice: "queue-thread", + confidence: 0.8, + probabilities: { "queue-thread": 0.87, none: 0.13 }, + }, + significance: { type: "score" as const, score: 1.41 }, + }; + let assess = ( + text = current.text, + candidate = answers as Record, + firstAnswers = triage as Record, + ) => + planEvents({ + channelId: "channel", + message: message("sqs-reason", text), + state, + first: firstAnswers, + candidates: [{ quote: text, start: 0, end: text.length, answers: candidate }], + }); + let accepted = assess(); + expect(accepted.events.map(event => event.type)).toEqual(["reason.added"]); + expect(accepted.events[0]).toMatchObject({ + threadId: "queue-thread", + source: { quote: current.text, role: "reason" }, + contribution: { targetId: "sqs-option", relation: "supports" }, + }); + expect( + assess(current.text, { + ...answers, + role: { + ...answers.role, + choice: "support", + probabilities: { reason: 0.44, support: 0.48, none: 0.08 }, + }, + }).events.map(event => event.type), + ).toEqual(["reason.added"]); + expect(assess(current.text, { ...answers, thread: confidentChoice("none") }).events) + .toEqual([]); + expect( + assess(current.text, answers, { + ...triage, + thread_target: confidentChoice("none"), + }).events, + ).toEqual([]); + expect( + assess(current.text, answers, { + ...triage, + c0_owned_unretracted: { type: "noul", noul: 0.3 }, + }).events, + ).toEqual([]); + expect(assess(current.text, { ...answers, option: confidentChoice("none") }).events) + .toEqual([]); + expect(assess(current.text, { ...answers, relation: confidentChoice("unrelated") }).events) + .toEqual([]); + expect( + assess(current.text, answers, { ...triage, reason: { type: "noul", noul: 0.4 } }) + .events, + ).toEqual([]); + expect( + assess(current.text, answers, { + ...triage, + significance: { type: "score", score: 0.9 }, + }).events, + ).toEqual([]); + expect( + assess(current.text, { + ...answers, + role: { + ...answers.role, + probabilities: { reason: 0.36, support: 0.4, option: 0.24 }, + }, + }).events, + ).toEqual([]); + expect(assess("I prefer SQS.").events).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-commitment-triage.test.ts b/apps/server/src/conversation-plan/pipeline-commitment-triage.test.ts new file mode 100644 index 00000000..22838abe --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-commitment-triage.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test"; +import { buildTargetingRequest, buildTriageRequest, QUESTION_SET_VERSION } from "./questions"; +import { extractQuotes } from "./quotes"; +import { seeded } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; + +test("candidate corroboration stays within the bounded second batch", () => { + let current = message("batch", "Use an outline. Keep it optional. Remember my choice."); + let request = buildTargetingRequest(current, [], seeded().threads, extractQuotes(current.text)); + expect(QUESTION_SET_VERSION).toBe("conversation-plan-8"); + // The seeded thread has no prior contributions, so none of the three spans asks duplicate. + expect(Object.keys(request.questions)).toHaveLength(36); + expect(Object.keys(request.questions).filter(key => key.endsWith("_duplicate"))).toEqual([]); + expect(request.questions.c2_new_option?.type).toBe("noul"); + expect(request.questions.c2_objection?.type).toBe("noul"); +}); + +test("question set four asks about purpose and re-raising only when discarded threads exist", () => { + let current = message("question-set", "Should we revisit the outline?"); + let threads = seeded().threads; + let triage = buildTriageRequest(current, [], threads); + expect(triage.questions.enough_purpose?.type).toBe("noul"); + expect(triage.questions.explicit_resolution?.instructions).toContain("propose"); + let candidate = extractQuotes(current.text); + let active = buildTargetingRequest(current, [], threads, candidate); + expect(active.questions.c0_raises_again).toBeUndefined(); + let discardedThreads = [{ ...threads[0], status: "discarded" as const }]; + let discarded = buildTargetingRequest(current, [], discardedThreads, candidate); + expect(discarded.questions.c0_raises_again?.type).toBe("noul"); + expect(discarded.questions.c0_explicit_resolution?.instructions).toContain("propose"); +}); + +test("purpose triage accepts a named planning topic without requiring a chosen approach", () => { + let auth = buildTriageRequest( + message("auth-purpose", "Okay we gotta figure out what we're doing for auth"), + [], + [], + ); + let vague = buildTriageRequest(message("vague", "We need to make a plan soon"), [], []); + let purpose = auth.questions.enough_purpose; + expect(purpose?.type).toBe("noul"); + if (purpose?.type !== "noul") throw new Error("purpose question is missing"); + expect(purpose.instructions).toContain("concrete planning topic"); + expect(purpose.instructions).toContain("Do not require a chosen solution"); + expect(purpose.criteria?.true).toContain("topic and intent to plan"); + expect(purpose.criteria?.false).toContain("no identifiable topic"); + expect((auth.state as { current: { text: string } }).current.text).toContain("auth"); + expect((vague.state as { current: { text: string } }).current.text).toBe( + "We need to make a plan soon", + ); +}); + +test("question triage separates planning purpose from a distinct answerable choice", () => { + let request = buildTriageRequest( + message("auth-purpose", "Okay we gotta figure out what we're doing for auth"), + [], + [], + ); + let question = request.questions.new_question; + expect(question?.type).toBe("noul"); + if (question?.type !== "noul") throw new Error("new question triage is missing"); + expect(question.instructions).toContain("distinct unresolved planning decision"); + expect(question.instructions).toContain("even when phrased as a statement"); + expect(question.instructions).toContain("merely announcing intent"); + expect(question.criteria?.true).toContain("declarative choice"); + expect(question.criteria?.false).toContain("no distinct question"); +}); + +test("triage selects a new target for an unanswered question with no current thread", () => { + let request = buildTriageRequest( + message("new-target", "Should we use Auth0 or build our own authentication?"), + [], + [], + ); + let target = request.questions.thread_target; + expect(target?.type).toBe("choice"); + if (target?.type !== "choice") throw new Error("thread targeting is missing"); + expect(target.instructions).toContain("If there are no current threads"); + expect(target.criteria.new).toContain("unanswered planning question"); + expect(target.criteria.none).toContain("No distinct planning question"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-contrast.test-fixtures.ts b/apps/server/src/conversation-plan/pipeline-contrast.test-fixtures.ts new file mode 100644 index 00000000..661aa7d4 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-contrast.test-fixtures.ts @@ -0,0 +1,98 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import { planEvents } from "./policy"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; + +export function stateWithOptions( + questionText: string, + threadId: string, + options: Array<{ id: string; text: string }>, +): ConversationPlan.State { + let [firstOption, ...rest] = options; + if (!firstOption) throw new Error("test thread needs an initial option"); + let state = stateWithOption(questionText, threadId, firstOption.id, firstOption.text); + for (let [index, option] of rest.entries()) { + let source = message(`option-${threadId}-${index + 1}`, option.text); + let thread = state.threads.find(item => item.id === threadId); + if (!thread) throw new Error("test option thread disappeared"); + state = applyInference(state, { + id: `option-added-${threadId}-${index + 1}`, + type: "option.added", + threadId, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: source.ts, + source: { + messageId: source.id, + author: source.author as ConversationPlan.SourceAuthor, + quote: source.text, + start: 0, + end: source.text.length, + role: "option", + }, + contribution: { + id: option.id, + text: option.text, + authoring: "quoted", + targetId: threadId, + }, + }, source); + } + return state; +} + +export function planD03ContrastiveM2(role: "option" | "reason") { + let threadId = "notification-thread"; + let relayId = "smtp-relay"; + let state = stateWithOptions( + "Which service sends transactional notifications?", + threadId, + [ + { id: relayId, text: "SMTP relay" }, + { id: "postmark", text: "Postmark" }, + { id: "ses", text: "SES" }, + ], + ); + let current = message( + "d03-m2", + "relay is cheap but delivery reports are rough.", + "Dan", + ); + let candidate = { + quote: current.text, + start: 0, + end: current.text.length, + }; + let result = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first(role === "option" ? { new_option: 0.98 } : { reason: 0.96 }), + act: confidentChoice(role === "option" ? "proposal" : "evaluation"), + thread_target: confidentChoice(threadId), + significance: { + type: "score", + score: 2, + confidence: 0.95, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0.05, "2": 0.9, "3": 0.05 }, + }, + }, + candidates: [{ + ...candidate, + answers: { + ...follow({ role, thread: threadId }), + option: confidentChoice(role === "option" ? "new" : relayId), + ...(role === "option" ? { chosen_option: confidentChoice("new") } : {}), + ...(role === "reason" ? { relation: confidentChoice("supports") } : {}), + new_option: { type: "noul", noul: role === "option" ? 0.98 : 0.05 }, + planning_substance: { type: "noul", noul: 0.95 }, + duplicate: { type: "noul", noul: 0.02 }, + }, + }], + }); + return { result, current, relayId }; +} diff --git a/apps/server/src/conversation-plan/pipeline-final-contrast-options.test.ts b/apps/server/src/conversation-plan/pipeline-final-contrast-options.test.ts new file mode 100644 index 00000000..b47df93a --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-contrast-options.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { planD03ContrastiveM2, stateWithOptions } from "./pipeline-contrast.test-fixtures"; + +test("D04's R2 follow-up stays an option despite a comma and terminal or", () => { + let threadId = "upload-originals"; + let text = "is R2 still worth comparing, or did we rule it out?"; + let current = message("m4", text, "Teo"); + let state = stateWithOptions( + "Where should image originals live?", + threadId, + [ + { id: "s3", text: "S3 object storage" }, + { id: "shared-disk", text: "the existing shared disk" }, + ], + ); + let result = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ new_option: 0.97 }), + act: confidentChoice("proposal"), + thread_target: confidentChoice(threadId), + significance: { + type: "score", + score: 2, + confidence: 0.95, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0.05, "2": 0.9, "3": 0.05 }, + }, + }, + candidates: [{ + quote: text, + start: 0, + end: 51, + answers: { + ...follow({ role: "option", thread: threadId }), + option: confidentChoice("new"), + chosen_option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.99 }, + planning_substance: { type: "noul", noul: 0.99 }, + duplicate: { type: "noul", noul: 0.01 }, + }, + }], + }); + expect(result.events.map(event => event.type)).toEqual(["option.added"]); + expect(result.events[0]).toMatchObject({ + type: "option.added", + threadId, + source: { + messageId: "m4", + author: { kind: "member", handle: "Teo" }, + quote: text, + start: 0, + end: 51, + role: "option", + }, + contribution: { text, authoring: "quoted", targetId: threadId }, + }); +}); + +test("D03's contrastive m2 cannot add a fourth option when classified option/new", () => { + let { result } = planD03ContrastiveM2("option"); + expect(result.events.filter(event => event.type === "option.added")).toEqual([]); +}); + +test("D03's contrastive m2 can remain a reason when independently classified", () => { + let { result, current, relayId } = planD03ContrastiveM2("reason"); + expect(result.events.map(event => event.type)).toEqual(["reason.added"]); + expect(result.events[0]).toMatchObject({ + type: "reason.added", + threadId: "notification-thread", + source: { quote: current.text, role: "reason" }, + contribution: { targetId: relayId }, + }); +}); diff --git a/apps/server/src/conversation-plan/pipeline-final-dual-role.test.ts b/apps/server/src/conversation-plan/pipeline-final-dual-role.test.ts new file mode 100644 index 00000000..3d6e003a --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-dual-role.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from "bun:test"; +import { initialState } from "./domain"; +import { planEvents } from "./policy"; +import { buildTriageRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { mockResult } from "./interpret.test-fixtures"; +import { follow, message } from "./policy-initial.test-fixtures"; + +test("a first clause classified as both question and option keeps all three quoted options", () => { + let current = message( + "dual-role-options", + "Should we use an off the shelf solution like Auth0? Or roll our own? Or use something like GitHub auth?", + "Jules", + ); + let quotes = extractQuotes(current.text); + let triage = mockResult(buildTriageRequest(current, [], [], quotes).questions, { + new_question: 0.97, + new_option: 0.91, + act: "question", + thread_target: "new", + significance: 2, + c0_owned_unretracted: 0.93, + c1_owned_unretracted: 0.94, + c2_owned_unretracted: 0.94, + }).answers; + let opening = follow({ role: "question", thread: "new", threadProbability: 0.92 }); + opening.role = { + type: "choice", + choice: "question", + confidence: 0.86, + probabilities: { question: 0.89, option: 0.11 }, + }; + opening.thread.confidence = 0.84; + opening.new_option = { type: "noul", noul: 0.91 }; + let second = follow({ role: "option", thread: "new" }); + second.role = { + type: "choice", + choice: "option", + confidence: 0.76, + probabilities: { option: 0.79, none: 0.21 }, + }; + second.thread = { + type: "choice", + choice: "new", + confidence: 0.32, + probabilities: { new: 0.66, none: 0.34 }, + }; + second.new_option = { type: "noul", noul: 0.94 }; + let third = follow({ role: "option", thread: "new" }); + third.role = { + type: "choice", + choice: "option", + confidence: 0.94, + probabilities: { option: 0.95, none: 0.05 }, + }; + third.thread = { + type: "choice", + choice: "new", + confidence: 0.51, + probabilities: { new: 0.75, none: 0.25 }, + }; + third.new_option = { type: "noul", noul: 0.94 }; + let output = planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: triage, + candidates: [opening, second, third].map((answers, index) => ({ + ...quotes[index]!, + answers, + })), + }); + expect(output.events.map(event => event.type)).toEqual([ + "thread.opened", + "option.added", + "option.added", + "option.added", + ]); + expect(output.events[0]).toMatchObject({ + question: current.text, + source: { quote: current.text, start: 0, end: current.text.length }, + }); + expect( + output.events.slice(1).map(event => + "source" in event && event.source + ? { quote: event.source.quote, start: event.source.start, end: event.source.end } + : undefined + ), + ) + .toEqual(quotes.map(quote => ({ quote: quote.quote, start: quote.start, end: quote.end }))); +}); diff --git a/apps/server/src/conversation-plan/pipeline-final-scoped-agreement.test.ts b/apps/server/src/conversation-plan/pipeline-final-scoped-agreement.test.ts new file mode 100644 index 00000000..4a375384 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-scoped-agreement.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "bun:test"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; +import { d01RobM7PolicyResult, d01SeedScopedChoiceProposal } from "./pipeline-scoped.test-fixtures"; + +test("D01 m7 independently agrees with Mei's pending Lexical spike choice", () => { + let linked = d01LinkedEditorCard(); + let pending = d01SeedScopedChoiceProposal(linked); + let { current, quotes, result } = d01RobM7PolicyResult(linked, { state: pending.state }); + expect(quotes).toEqual([ + { quote: "yep, Lexical for the spike.", start: 0, end: 27 }, + { quote: "not a final library call yet.", start: 28, end: 57 }, + ]); + let agreements = result.events.filter(event => + (event as { type: string }).type === "scoped-choice.agreed" + ); + + expect(agreements).toHaveLength(1); + expect(agreements[0]).toMatchObject({ + type: "scoped-choice.agreed", + id: expect.any(String), + proposalId: pending.proposal.id, + threadId: linked.threadId, + cardId: "01M3QAQ8HM8DVYA9E4N28QCQ7T", + optionId: pending.lexicalId, + label: "Lexical", + scope: "spike", + source: { + messageId: current.id, + author: { kind: "member", handle: "Rob" }, + quote: quotes[0]!.quote, + start: 0, + end: 27, + role: "support", + }, + }); + expect(result.events.filter(event => event.type === "settle.agreed")).toEqual([]); + expect(result.events.filter(event => event.type === "settle.suggested")).toEqual([]); + expect(result.events.filter(event => event.type === "decision.recorded")).toEqual([]); + expect(result.events.filter(event => event.type === "option.added")).toEqual([]); + expect( + result.events.filter(event => (event as { type: string }).type === "card.answered"), + ).toEqual([]); + expect(pending.state.threads[0]!.pendingScopedChoice).toMatchObject({ + proposalId: pending.proposal.id, + cardId: "01M3QAQ8HM8DVYA9E4N28QCQ7T", + optionId: pending.lexicalId, + label: "Lexical", + scope: "spike", + proposer: "Mei", + messageId: "d01-m6", + }); + expect(pending.state.threads[0]!.pendingSettle).toBeUndefined(); + expect(pending.state.threads[0]!.decisionHistory).toEqual([]); + expect(linked.state.threads[0]!.status).toBe("exploring"); + expect(linked.linkedCards.get(linked.threadId)?.options).toEqual(linked.options); +}); + +test("D01 m7 agreement binds the latest pending proposal ID", () => { + let linked = d01LinkedEditorCard(); + let earlier = d01SeedScopedChoiceProposal(linked, "d01-m6-scoped-earlier"); + let current = d01SeedScopedChoiceProposal( + { ...linked, state: earlier.state }, + "d01-m6-scoped-current", + "d01-m6-scoped-current-message", + ); + let { result } = d01RobM7PolicyResult(linked, { state: current.state }); + let agreements = result.events.filter(event => + (event as { type: string }).type === "scoped-choice.agreed" + ); + + expect(agreements).toHaveLength(1); + expect(agreements[0]).toMatchObject({ proposalId: current.proposal.id }); + expect(agreements[0]).not.toMatchObject({ proposalId: earlier.proposal.id }); +}); diff --git a/apps/server/src/conversation-plan/pipeline-final-scoped-conditional.test.ts b/apps/server/src/conversation-plan/pipeline-final-scoped-conditional.test.ts new file mode 100644 index 00000000..4ba6c86f --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-scoped-conditional.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; + +test("an immediately conditioned Lexical preference cannot create a scoped Save", () => { + let { state, threadId, linkedCards } = d01LinkedEditorCard(); + let lexicalId = "01M3QAQWN9TYWMFW3D0EYAZY8H"; + let text = "I would pick Lexical for the spike; if pasted tables work."; + let current = message("d01-quoted-spike", text, "Mei"); + let quotes = extractQuotes(text); + expect(quotes).toEqual([ + { quote: "I would pick Lexical for the spike;", start: 0, end: 35 }, + { quote: "if pasted tables work.", start: 36, end: 58 }, + ]); + let result = planEvents({ + channelId: "channel", + message: current, + state, + linkedCards, + first: { + ...first({ + new_option: 0.89, + c0_owned_unretracted: 0.98, + }), + act: { + type: "choice", + choice: "proposal", + confidence: 0.95, + probabilities: { + question: 0, + proposal: 0.98, + evaluation: 0.01, + commitment: 0.01, + correction: 0, + other: 0, + }, + }, + thread_target: confidentChoice(threadId), + }, + candidates: quotes.map((candidate, index) => ({ + ...candidate, + answers: index === 0 + ? { + ...follow({ role: "support", thread: threadId }), + option: confidentChoice(lexicalId), + chosen_option: confidentChoice(lexicalId), + relation: confidentChoice("supports"), + support: { type: "noul", noul: 0.99 }, + new_option: { type: "noul", noul: 0.9 }, + planning_substance: { type: "noul", noul: 0.9 }, + duplicate: { type: "noul", noul: 0.05 }, + } + : { + ...follow({ role: "none", thread: threadId }), + option: confidentChoice("none"), + chosen_option: confidentChoice("none"), + }, + })), + }); + + expect( + result.events.filter(event => (event as { type: string }).type === "scoped-choice.proposed"), + ).toEqual([]); + expect(result.events.filter(event => event.type === "option.added")).toEqual([]); + expect(result.events.filter(event => event.type === "settle.suggested")).toEqual([]); + expect(result.events.filter(event => event.type === "decision.recorded")).toEqual([]); + expect(state.threads[0]!.pendingScopedChoice).toBeUndefined(); +}); + +test("a generic spike request without an option preference does not create a global Save", () => { + let { state, threadId, linkedCards } = d01LinkedEditorCard(); + let quote = "Let's run a quick spike before picking an editor."; + let current = message("d01-m5-generic-spike", quote, "Mei"); + let result = planEvents({ + channelId: "channel", + message: current, + state, + linkedCards, + first: { + ...first({ new_option: 0.05, c0_owned_unretracted: 0.95 }), + act: confidentChoice("proposal"), + thread_target: confidentChoice(threadId), + }, + candidates: [{ + quote, + start: 0, + end: quote.length, + answers: { + ...follow({ role: "none", thread: threadId }), + option: confidentChoice("none"), + chosen_option: confidentChoice("none"), + new_option: { type: "noul", noul: 0.05 }, + planning_substance: { type: "noul", noul: 0.12 }, + }, + }], + }); + + expect(result.events).toEqual([]); + expect(state.threads[0]!.pendingSettle).toBeUndefined(); + expect(state.threads[0]!.decisionHistory).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-final-scoped-duplicate.test.ts b/apps/server/src/conversation-plan/pipeline-final-scoped-duplicate.test.ts new file mode 100644 index 00000000..d756108b --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-scoped-duplicate.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { first, message } from "./policy-initial.test-fixtures"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; + +test("D01 m6 cannot add a fifth option when targeting existing linked Lexical", () => { + let { state, threadId, options, linkedCards } = d01LinkedEditorCard(); + let lexicalId = "01M3QAQWN9TYWMFW3D0EYAZY8H"; + let quote = "I'd pick Lexical for the spike;"; + let current = message("d01-m6", quote, "Mei"); + let result = planEvents({ + channelId: "channel", + message: current, + state, + linkedCards, + first: { + ...first({ + new_option: 0.89, + c0_owned_unretracted: 0.88, + c1_owned_unretracted: 0.94, + c2_owned_unretracted: 0.95, + }), + act: { + type: "choice", + choice: "proposal", + confidence: 0.73, + probabilities: { + question: 0, + proposal: 0.78, + evaluation: 0.2, + commitment: 0.02, + correction: 0, + other: 0, + }, + }, + thread_target: { + type: "choice", + choice: threadId, + confidence: 0.75, + probabilities: { [threadId]: 0.84, new: 0.06, none: 0.1 }, + }, + significance: { + type: "score", + score: 2.26, + confidence: 0.69, + legend: { "0": "none", "1": "low", "2": "normal", "3": "high" }, + probabilities: { "0": 0.01, "1": 0.02, "2": 0.69, "3": 0.28 }, + }, + }, + candidates: [{ + quote, + start: 0, + end: quote.length, + answers: { + role: { + type: "choice", + choice: "option", + confidence: 0.87, + probabilities: { + question: 0, + option: 0.89, + reason: 0, + constraint: 0, + support: 0.05, + objection: 0, + resolution: 0.05, + reopening: 0, + none: 0.01, + }, + }, + thread: { + type: "choice", + choice: threadId, + confidence: 0.95, + probabilities: { [threadId]: 0.97, new: 0.03, none: 0 }, + }, + option: { + type: "choice", + choice: lexicalId, + confidence: 1, + probabilities: Object.fromEntries( + options.map(option => [option.id, option.id === lexicalId ? 1 : 0]) + .concat([["new", 0], ["none", 0]]), + ), + }, + chosen_option: { + type: "choice", + choice: lexicalId, + confidence: 0.98, + probabilities: Object.fromEntries( + options.map(option => [option.id, option.id === lexicalId ? 0.98 : 0]) + .concat([["new", 0], ["none", 0.02]]), + ), + }, + relation: { + type: "choice", + choice: "supports", + confidence: 0.66, + probabilities: { + supports: 0.73, + challenges: 0.05, + qualifies: 0.13, + replaces: 0.04, + unrelated: 0.05, + }, + }, + new_option: { type: "noul", noul: 0.85 }, + planning_substance: { type: "noul", noul: 0.51 }, + support: { type: "noul", noul: 0.92 }, + objection: { type: "noul", noul: 0.05 }, + duplicate: { type: "noul", noul: 0.15 }, + explicit_resolution: { type: "noul", noul: 0.1 }, + reopening: { type: "noul", noul: 0.06 }, + material_objection: { type: "noul", noul: 0.1 }, + }, + }], + }); + + expect(state.threads[0]!.contributions.filter(item => item.kind === "option")).toEqual([]); + expect(result.events.filter(event => event.type === "option.added")).toEqual([]); + expect(state.threads[0]!.contributions.filter(item => item.kind === "option")).toEqual([]); + expect(linkedCards.get(threadId)?.options).toEqual(options); +}); diff --git a/apps/server/src/conversation-plan/pipeline-final-scoped-proposal.test.ts b/apps/server/src/conversation-plan/pipeline-final-scoped-proposal.test.ts new file mode 100644 index 00000000..78fe697e --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-scoped-proposal.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; + +test("D01 m6 proposes a spike-scoped Save for existing linked Lexical", () => { + let { state, threadId, options, linkedCards } = d01LinkedEditorCard(); + let lexicalId = "01M3QAQWN9TYWMFW3D0EYAZY8H"; + let quote = "I'd pick Lexical for the spike;"; + let current = message( + "d01-m6", + `agreed. ${quote} still want to see how it handles pasted tables.`, + "Mei", + ); + let result = planEvents({ + channelId: "channel", + message: current, + state, + linkedCards, + first: { + ...first({ + new_option: 0.89, + c0_owned_unretracted: 0.88, + c1_owned_unretracted: 0.94, + c2_owned_unretracted: 0.95, + }), + act: { + type: "choice", + choice: "proposal", + confidence: 0.73, + probabilities: { + question: 0, + proposal: 0.78, + evaluation: 0.2, + commitment: 0.02, + correction: 0, + other: 0, + }, + }, + thread_target: { + type: "choice", + choice: threadId, + confidence: 0.75, + probabilities: { [threadId]: 0.84, new: 0.06, none: 0.1 }, + }, + significance: { + type: "score", + score: 2.26, + confidence: 0.69, + legend: { "0": "none", "1": "low", "2": "normal", "3": "high" }, + probabilities: { "0": 0.01, "1": 0.02, "2": 0.69, "3": 0.28 }, + }, + }, + candidates: [{ + quote, + start: 8, + end: 39, + answers: { + ...follow({ role: "support", thread: threadId }), + role: { + type: "choice", + choice: "support", + confidence: 0.87, + probabilities: { + question: 0, + option: 0.05, + reason: 0, + constraint: 0, + support: 0.89, + objection: 0, + resolution: 0.05, + reopening: 0, + none: 0.01, + }, + }, + option: confidentChoice(lexicalId), + chosen_option: confidentChoice(lexicalId), + relation: confidentChoice("supports"), + new_option: { type: "noul", noul: 0.85 }, + planning_substance: { type: "noul", noul: 0.51 }, + support: { type: "noul", noul: 0.92 }, + duplicate: { type: "noul", noul: 0.15 }, + }, + }], + }); + let scoped = result.events.filter(event => + (event as { type: string }).type === "scoped-choice.proposed" + ); + + expect(scoped).toHaveLength(1); + expect(scoped[0]).toMatchObject({ + type: "scoped-choice.proposed", + id: expect.any(String), + threadId, + cardId: "01M3QAQ8HM8DVYA9E4N28QCQ7T", + optionId: lexicalId, + label: "Lexical", + scope: "spike", + source: { + messageId: current.id, + author: { kind: "member", handle: "Mei" }, + quote, + start: 8, + end: 39, + role: "support", + }, + }); + expect(result.events.filter(event => event.type === "option.added")).toEqual([]); + expect(result.events.filter(event => event.type === "settle.suggested")).toEqual([]); + expect(result.events.filter(event => event.type === "decision.recorded")).toEqual([]); + expect(state.threads[0]!.pendingSettle).toBeUndefined(); + expect(state.threads[0]!.decisionHistory).toEqual([]); + expect(linkedCards.get(threadId)?.options).toEqual(options); +}); diff --git a/apps/server/src/conversation-plan/pipeline-final-scoped-refusals.test.ts b/apps/server/src/conversation-plan/pipeline-final-scoped-refusals.test.ts new file mode 100644 index 00000000..4cd9e5ae --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-scoped-refusals.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; +import { d01RobM7PolicyResult, d01SeedScopedChoiceProposal } from "./pipeline-scoped.test-fixtures"; + +test("D01 m7 cannot agree from quoted, self, mismatched, or unpending evidence", () => { + let linked = d01LinkedEditorCard(); + let pending = d01SeedScopedChoiceProposal(linked); + let cases: Array<{ + name: string; + state: ConversationPlan.State; + owned?: number; + author?: string; + text?: string; + optionId?: string; + }> = [ + { + name: "low ownership", + state: pending.state, + owned: 0.2, + }, + { + name: "quoted affirmation", + state: pending.state, + text: 'Nia said: "yep, Lexical for the spike."', + owned: 0.89, + }, + { + name: "the proposer repeats their own preference", + state: pending.state, + author: "Mei", + }, + { + name: "another option is named", + state: pending.state, + text: "yep, Tiptap for the spike. not a final library call yet.", + optionId: linked.options[0]!.id, + }, + { + name: "another scope is named", + state: pending.state, + text: "yep, Lexical for the final call. not a final library call yet.", + }, + { + name: "there is no pending proposal", + state: linked.state, + }, + ]; + + for (let [index, item] of cases.entries()) { + let { result } = d01RobM7PolicyResult(linked, { + state: item.state, + owned: item.owned, + author: item.author, + text: item.text, + optionId: item.optionId, + messageId: `d01-m7-negative-${index}`, + }); + expect( + result.events.filter(event => (event as { type: string }).type === "scoped-choice.agreed"), + ).toEqual([]); + expect(result.events.filter(event => event.type === "settle.agreed")).toEqual([]); + expect(result.events.filter(event => event.type === "decision.recorded")).toEqual([]); + } +}); diff --git a/apps/server/src/conversation-plan/pipeline-final-scoped-stance.test.ts b/apps/server/src/conversation-plan/pipeline-final-scoped-stance.test.ts new file mode 100644 index 00000000..69ace7bf --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-final-scoped-stance.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test"; +import { applyInference } from "./domain"; +import { applyEvent } from "./events"; +import { planEvents } from "./policy"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; +import { d01RobM7PolicyResult, d01SeedScopedChoiceProposal } from "./pipeline-scoped.test-fixtures"; + +test("a new stance links scoped support only with a confident owned target", () => { + let linked = d01LinkedEditorCard(); + let pending = d01SeedScopedChoiceProposal(linked); + let withNamedOption = applyEvent(pending.state, { + id: "scoped-stance-named-option", + type: "option.added", + threadId: linked.threadId, + observedThreadVersion: pending.state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Mei" }, + at: 1002, + contribution: { id: pending.lexicalId, text: "Lexical", authoring: "human-edited" }, + }); + let accepted = d01RobM7PolicyResult(linked, { state: pending.state }); + let agreement = accepted.result.events.find(event => event.type === "scoped-choice.agreed"); + if (!agreement) throw new Error("scoped agreement missing"); + let withAgreement = applyInference(pending.state, agreement, accepted.current); + let cases = [ + { + name: "proposer with exact target", + state: pending.state, + speaker: "Mei", + quote: "I oppose Lexical for the spike.", + option: pending.lexicalId, + expected: pending.proposal.id, + }, + { + name: "proposer with exact named option", + state: withNamedOption, + speaker: "Mei", + quote: "I oppose Lexical for the spike.", + option: pending.lexicalId, + chosenOption: pending.lexicalId, + expectedOptionId: pending.lexicalId, + expected: pending.proposal.id, + }, + { + name: "proposer with mismatched chosen option", + state: withNamedOption, + speaker: "Mei", + quote: "I oppose Lexical for the spike.", + option: pending.lexicalId, + chosenOption: "none", + expectedOptionId: pending.lexicalId, + expected: null, + }, + { + name: "agreeing supporter with exact target", + state: withAgreement, + speaker: "Rob", + quote: "I oppose Lexical for the spike.", + option: pending.lexicalId, + expected: pending.proposal.id, + }, + { + name: "non-supporter with exact target", + state: pending.state, + speaker: "Nia", + quote: "I oppose Lexical for the spike.", + option: pending.lexicalId, + expected: null, + }, + { + name: "proposer with unrelated objection", + state: pending.state, + speaker: "Mei", + quote: "I oppose making the toolbar blue.", + option: "none", + expected: null, + }, + ]; + for (let [index, item] of cases.entries()) { + let current = message(`scoped-stance-${index}`, item.quote, item.speaker); + let result = planEvents({ + channelId: "channel", + message: current, + state: item.state, + linkedCards: linked.linkedCards, + first: { + ...first({ c0_owned_unretracted: 0.98 }), + act: confidentChoice("evaluation"), + thread_target: confidentChoice(linked.threadId), + }, + candidates: [{ + quote: item.quote, + start: 0, + end: item.quote.length, + answers: { + ...follow({ role: "objection", thread: linked.threadId }), + option: confidentChoice(item.option), + chosen_option: confidentChoice(item.chosenOption ?? item.option), + }, + }], + }); + let stances = result.events.filter(event => event.type === "stance.changed"); + expect(stances, item.name).toHaveLength(1); + expect(stances[0], item.name).toMatchObject({ + type: "stance.changed", + optionId: item.expectedOptionId, + scopedProposalId: item.expected, + }); + } +}); diff --git a/apps/server/src/conversation-plan/pipeline-lifecycle-2.test.ts b/apps/server/src/conversation-plan/pipeline-lifecycle-2.test.ts new file mode 100644 index 00000000..1b05da3c --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-lifecycle-2.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { first, follow, message } from "./policy-initial.test-fixtures"; +import { decided } from "./pipeline-lifecycle.test-fixtures"; + +test("material concern and direct request only propose reopening diagnostics", () => { + let state = decided(); + let objection = message("m3", "The blank link is too easy to miss."); + let review = planEvents({ + channelId: "channel", + message: objection, + state, + first: first({ objection: 0.9 }), + candidates: [ + { + quote: objection.text, + start: 0, + end: objection.text.length, + answers: follow({ role: "objection", thread: "thread-a", material_objection: 0.92 }), + }, + ], + }); + expect(review.events.map((event) => event.type)).toContain("candidate.proposed"); + let reopen = message("m4", "Reopen the outline decision."); + let direct = planEvents({ + channelId: "channel", + message: reopen, + state, + first: first({ reopening: 0.98 }), + candidates: [ + { + quote: reopen.text, + start: 0, + end: reopen.text.length, + answers: follow({ role: "reopening", thread: "thread-a", reopening: 0.98 }), + }, + ], + }); + expect(direct.events.map((event) => event.type)).toEqual(["candidate.proposed"]); + expect(direct.events[0]).toMatchObject({ candidate: { kind: "reopening" } }); + expect(state.threads[0].status).toBe("decided"); + expect(state.threads[0].decisionHistory).toHaveLength(1); +}); + +test("a .69 material concern proposes one human reopening review for a decided thread", () => { + let state = decided(); + let current = message( + "material", + "I'm worried the blank link is hidden. A newcomer may miss it.", + ); + let quotes = extractQuotes(current.text); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ objection: 0.58 }), + significance: { + type: "score", + score: 1.83, + confidence: 0.8, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0.17, "2": 0.83, "3": 0 }, + }, + }, + candidates: quotes.map((quote) => ({ + ...quote, + answers: follow({ role: "objection", thread: "thread-a", material_objection: 0.69 }), + })), + }); + expect(output.events.map((event) => event.type)).toEqual(["candidate.proposed"]); + expect(output.outcomes).toHaveLength(2); + expect(output.outcomes[0].status).toBe("review"); + expect(output.outcomes[1].gate).toContain("already proposed"); + expect(state.threads[0].status).toBe("decided"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-lifecycle.test-fixtures.ts b/apps/server/src/conversation-plan/pipeline-lifecycle.test-fixtures.ts new file mode 100644 index 00000000..1132d916 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-lifecycle.test-fixtures.ts @@ -0,0 +1,31 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyEvent } from "./events"; +import { member } from "./policy-initial.test-fixtures"; +import { seeded } from "./interpret.test-fixtures"; + +export function decided(): ConversationPlan.State { + let state = seeded(); + return applyEvent(state, { + id: "card:decision", + type: "decision.recorded", + threadId: "thread-a", + observedThreadVersion: 1, + origin: "human", + actor: member("Ana"), + at: 1000, + text: "Use an optional outline", + explicit: true, + }); +} + +export function discarded(state: ConversationPlan.State): ConversationPlan.State { + return applyEvent(state, { + id: "card:discarded", + type: "thread.discarded", + threadId: "thread-a", + observedThreadVersion: state.threads[0].version, + origin: "human", + actor: member("Ana"), + at: 1001, + }); +} diff --git a/apps/server/src/conversation-plan/pipeline-linked-card-resolution.test.ts b/apps/server/src/conversation-plan/pipeline-linked-card-resolution.test.ts new file mode 100644 index 00000000..20d7efe6 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-linked-card-resolution.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "bun:test"; +import { optionIdFor, planEvents } from "./policy"; +import { seeded, withOption } from "./interpret.test-fixtures"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; + +// Complete original archive pipeline.test.ts callbacks; shared helpers remain unchanged. +test("owned exact resolution mirrors a linked card option and suggests it atomically", () => { + let current = message("m-card", "Let's just go with the VPS option."); + let state = seeded(); + state.threads[0]!.questionnaireId = "card-a"; + let id = optionIdFor("channel", "card-option", 0, 1000); + let cards = new Map([["thread-a", { + cardId: "card-a", + options: [{ id, label: "VPS option" }], + }]]); + let input = { + channelId: "channel", + message: current, + state, + linkedCards: cards, + first: first({ explicit_resolution: 0.98 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.98 }), + chosen_option: confidentChoice(id), + }, + }], + }; + let result = planEvents(input); + expect(result.events.map(event => event.type)).toEqual(["option.added", "settle.suggested"]); + expect(result.events[0]).toMatchObject({ + contribution: { id, text: "VPS option", authoring: "scribe" }, + }); + expect(result.events[1]).toMatchObject({ optionId: id, source: { quote: current.text } }); + let mixedRole = { + ...input, + first: { ...input.first, act: confidentChoice("commitment") }, + candidates: [{ + ...input.candidates[0]!, + answers: { + ...input.candidates[0]!.answers, + role: { + type: "choice" as const, + choice: "resolution", + confidence: 0.72, + probabilities: { resolution: 0.72, option: 0.2, none: 0.08 }, + }, + }, + }], + }; + expect(planEvents(mixedRole).events.map(event => event.type)) + .toEqual(["option.added", "settle.suggested"]); + let recommendation = message("recommend", "We should use the VPS option."); + expect( + planEvents({ + ...mixedRole, + message: recommendation, + first: { ...input.first, act: confidentChoice("proposal") }, + candidates: [{ + ...mixedRole.candidates[0]!, + quote: recommendation.text, + end: recommendation.text.length, + answers: { + ...mixedRole.candidates[0]!.answers, + support: { type: "noul" as const, noul: 0.95 }, + }, + }], + }).events.map(event => event.type), + ).toEqual(["option.added", "settle.suggested"]); + let mirrored = withOption(state); + mirrored.threads[0]!.contributions[0]!.id = id; + expect(planEvents({ ...input, state: mirrored }).events.map(event => event.type)) + .toEqual(["settle.suggested"]); + expect( + planEvents({ + ...input, + linkedCards: new Map([["thread-a", { + cardId: "other-card", + options: [{ id, label: "VPS option" }], + }]]), + }).events, + ).toEqual([]); + expect(planEvents({ ...input, first: first({ c0_owned_unretracted: 0.4 }) }).events) + .toEqual([]); + let agent = { ...current, author: { kind: "agent" as const } }; + expect(planEvents({ ...input, message: agent }).events).toEqual([]); + expect( + planEvents({ + ...input, + candidates: [{ + ...input.candidates[0]!, + answers: { + ...input.candidates[0]!.answers, + chosen_option: { + type: "choice" as const, + choice: id, + confidence: 0.6, + probabilities: { [id]: 0.6, none: 0.4 }, + }, + }, + }], + }).events, + ).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-linked-card.test.ts b/apps/server/src/conversation-plan/pipeline-linked-card.test.ts new file mode 100644 index 00000000..fe6845cf --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-linked-card.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; +import type { JevQuestion } from "./jev"; +import { optionIdFor, planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { buildCandidateTargetingRequest } from "./questions"; +import { seeded } from "./interpret.test-fixtures"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; + +// Complete original archive pipeline.test.ts callbacks; shared helpers remain unchanged. +test("targeting lists all ten current linked card options, including beyond thread truncation", () => { + let state = seeded(); + state.threads[0]!.questionnaireId = "card-a"; + let ids = Array.from( + { length: 10 }, + (_, index) => optionIdFor("channel", `card-${index}`, 0, 1000), + ); + let cards = new Map([["thread-a", { + cardId: "card-a", + options: ids.map((id, index) => ({ id, label: `Visible choice ${index}` })), + }]]); + let current = message("choice", "Let's go with visible choice nine."); + let request = buildCandidateTargetingRequest( + current, + [], + state.threads, + extractQuotes(current.text), + 0, + state.events, + cards, + ); + let criteria = + (request.questions.c0_chosen_option as Extract).criteria; + expect(ids.every(id => id in criteria)).toBe(true); + expect(criteria[ids[9]!]).toContain("Visible choice 9"); + expect( + (request.state as { threads: Array<{ options: Array<{ id: string }> }> }).threads[0]!.options, + ) + .toHaveLength(10); +}); + +test("two distinct linked card choices in one message cannot create competing Save prompts", () => { + let current = message("competing", "Let's go with VPS. Let's go with managed hosting."); + let state = seeded(); + state.threads[0]!.questionnaireId = "card-a"; + let vps = optionIdFor("channel", "vps", 0, 1000); + let managed = optionIdFor("channel", "managed", 0, 1000); + let quotes = extractQuotes(current.text); + let cards = new Map([["thread-a", { + cardId: "card-a", + options: [{ id: vps, label: "VPS" }, { id: managed, label: "Managed hosting" }], + }]]); + let result = planEvents({ + channelId: "channel", + message: current, + state, + linkedCards: cards, + first: { ...first({ explicit_resolution: 0.98 }), act: confidentChoice("commitment") }, + candidates: quotes.map((quote, index) => ({ + ...quote, + answers: { + ...follow({ role: "resolution", thread: "thread-a", explicit_resolution: 0.98 }), + chosen_option: confidentChoice(index === 0 ? vps : managed), + }, + })), + }); + expect(result.events).toEqual([]); + expect(result.outcomes.every(item => item.gate === "competing choice needs review")).toBe(true); +}); diff --git a/apps/server/src/conversation-plan/pipeline-linked-option.test-fixtures.ts b/apps/server/src/conversation-plan/pipeline-linked-option.test-fixtures.ts new file mode 100644 index 00000000..d1b9fc8d --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-linked-option.test-fixtures.ts @@ -0,0 +1,22 @@ +import { applyInference } from "./domain"; +import { planEvents } from "./policy"; +import { d01RecordedOpening } from "./policy-terminal.test-fixtures"; + +export function d01LinkedEditorCard() { + let opening = d01RecordedOpening(); + let state = applyInference(opening.state, planEvents(opening).events[0]!, opening.message); + let threadId = state.threads[0]!.id; + let cardId = "01M3QAQ8HM8DVYA9E4N28QCQ7T"; + state.threads[0]!.questionnaireId = cardId; + let options = [ + { id: "01M3QAQWM9QHKKX1YV7EEYVX90", label: "Tiptap" }, + { id: "01M3QAQWMTWFQQNS5DNDG66X4N", label: "Bare ProseMirror" }, + { id: "01M3QAQWN9TYWMFW3D0EYAZY8H", label: "Lexical" }, + { + id: "01M3QAQWNRJC3B4P6TD2HHBYKS", + label: "Native Selection and Range with a custom document model", + }, + ]; + let linkedCards = new Map([[threadId, { cardId, options }]]); + return { state, threadId, options, linkedCards }; +} diff --git a/apps/server/src/conversation-plan/pipeline-option-1.test.ts b/apps/server/src/conversation-plan/pipeline-option-1.test.ts new file mode 100644 index 00000000..c0415c33 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-option-1.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { seeded } from "./interpret.test-fixtures"; +import { first, follow, message } from "./policy-initial.test-fixtures"; + +test("triage corroboration can accept a useful option with uncertain role, while vague chatter stays unlinked", () => { + let state = seeded(); + let current = message("optional", "Keep setup optional."); + let option = follow({ role: "option", thread: "thread-a" }); + option.role = { + type: "choice", + choice: "option", + confidence: 0.56, + probabilities: { option: 0.56, question: 0.42, none: 0.02 }, + }; + let useful = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ new_option: 0.91 }), + significance: { + type: "score", + score: 2.4, + confidence: 0.8, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0, "2": 0.6, "3": 0.4 }, + }, + }, + candidates: [{ quote: current.text, start: 0, end: current.text.length, answers: option }], + }); + expect(useful.events.map((event) => event.type)).toEqual(["option.added"]); + let vague = message("vague", "That sounds like a lot for the first hour."); + let objection = follow({ role: "objection", thread: "thread-a" }); + let ignored = planEvents({ + channelId: "channel", + message: vague, + state, + first: { + ...first({ objection: 0.52, evidence: 0.6 }), + significance: { + type: "score", + score: 0.65, + confidence: 0.9, + legend: { "0": "chatter", "1": "minor" }, + probabilities: { "0": 0.35, "1": 0.65 }, + }, + }, + candidates: [{ quote: vague.text, start: 0, end: vague.text.length, answers: objection }], + }); + expect(ignored.events).toEqual([]); +}); + +test("one strong option quote cannot authorize another quote's weak option label", () => { + let state = seeded(); + let current = message( + "mixed-role", + "Start with an optional outline. I'll check the draft tomorrow.", + ); + let quotes = extractQuotes(current.text); + let weakOption = { + ...follow({ role: "option", thread: "thread-a" }), + role: { + type: "choice" as const, + choice: "option", + confidence: 0.56, + probabilities: { option: 0.56, none: 0.42, question: 0.02 }, + }, + }; + let result = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ new_option: 0.95 }), + significance: { + type: "score", + score: 2, + confidence: 1, + legend: { "0": "chatter", "1": "minor", "2": "useful" }, + probabilities: { "0": 0, "1": 0, "2": 1 }, + }, + }, + candidates: [ + { ...quotes[0], answers: { ...weakOption, new_option: { type: "noul", noul: 0.95 } } }, + { ...quotes[1], answers: { ...weakOption, new_option: { type: "noul", noul: 0.05 } } }, + ], + }); + expect(result.events.map((event) => event.type)).toEqual(["option.added"]); + expect(result.outcomes[1]).toMatchObject({ status: "ignored", start: quotes[1].start }); +}); diff --git a/apps/server/src/conversation-plan/pipeline-option-3.test.ts b/apps/server/src/conversation-plan/pipeline-option-3.test.ts new file mode 100644 index 00000000..822dcfa4 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-option-3.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from "bun:test"; +import type { Chat } from "@chopin/protocol"; +import { applyEvent } from "./events"; +import { optionIdFor, planEvents } from "./policy"; +import { seeded, seededOptionId, settledBy } from "./interpret.test-fixtures"; +import { first, follow, member, message } from "./policy-initial.test-fixtures"; + +test("support for another option is not agreement despite a high generic score", () => { + let state = settledBy(seeded(), "bob"); + let otherOptionId = optionIdFor("channel", "other-option", 0, 1000); + state = applyEvent(state, { + id: "human-other-option", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0].version, + origin: "human", + actor: member("alice"), + at: 1001, + contribution: { + id: otherOptionId, + text: "Use a required outline.", + authoring: "human-edited", + }, + }); + let current = message("other-support", "I support the required outline.", "alice"); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ support: 0.95 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role: "support", thread: "thread-a" }), + option: { + type: "choice", + choice: otherOptionId, + confidence: 0.95, + probabilities: { [otherOptionId]: 0.95, [seededOptionId]: 0.05 }, + }, + agrees_with_settle: { type: "noul", noul: 0.99 }, + }, + }], + }); + expect(output.events.map((event) => event.type)).toEqual(["stance.changed"]); + expect(output.events[0]).toMatchObject({ optionId: otherOptionId }); + expect(state.threads[0].pendingSettle?.optionId).toBe(seededOptionId); +}); + +test("weak, self, agent, negative, and wrong-role evidence cannot agree", () => { + let state = settledBy(seeded(), "bob"); + for ( + let [id, text, handle, role, agreement] of [ + ["weak", "Sounds good to me.", "alice", "support", 0.5], + ["self", "Sounds good to me.", "bob", "support", 0.95], + ["negative", "I don't agree with that.", "alice", "none", 0.05], + ["disagree", "I disagree with that.", "alice", "none", 0.05], + ["reported", "She said it sounds good.", "alice", "none", 0.05], + ["quoted", "Please quote ‘sounds good to me’.", "alice", "none", 0.05], + ["sarcastic", "Sure 🙄", "alice", "none", 0.05], + ["objection", "I object to that.", "alice", "objection", 0.95], + ] as const + ) { + let current = message(id, text, handle); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ support: 0.9 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...follow({ role, thread: "thread-a" }), + agrees_with_settle: { type: "noul", noul: agreement }, + }, + }], + }); + expect(output.events.map((event) => event.type)).not.toContain("settle.agreed"); + } + let agentMessage: Chat.Entry = { + ...message("agent-assent", "Sounds good to me."), + author: { kind: "agent" }, + }; + let agent = planEvents({ + channelId: "channel", + message: agentMessage, + state, + first: first({ support: 0.95 }), + candidates: [{ + quote: agentMessage.text, + start: 0, + end: agentMessage.text.length, + answers: { + ...follow({ role: "none", thread: "thread-a" }), + agrees_with_settle: { type: "noul", noul: 0.95 }, + }, + }], + }); + expect(agent.events).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-ordinary-save-pending.test-fixtures.ts b/apps/server/src/conversation-plan/pipeline-ordinary-save-pending.test-fixtures.ts new file mode 100644 index 00000000..33cc63db --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-ordinary-save-pending.test-fixtures.ts @@ -0,0 +1,60 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import { message } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; + +// Exact archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2 pipeline.test.ts declaration. +export function queueWithPendingSave(): { state: ConversationPlan.State; proposal: Chat.Entry } { + let state = stateWithOption( + "Which queue should handle background jobs?", + "queue-thread", + "sqs-option", + "Use SQS.", + ); + let postgres = message("postgres-queue-option", "Use a PostgreSQL queue."); + state = applyInference(state, { + id: postgres.id, + type: "option.added", + threadId: "queue-thread", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: postgres.ts, + source: { + messageId: postgres.id, + author: postgres.author as ConversationPlan.SourceAuthor, + quote: postgres.text, + start: 0, + end: postgres.text.length, + role: "option", + }, + contribution: { + id: "postgres-option", + text: postgres.text, + authoring: "quoted", + targetId: "queue-thread", + }, + }, postgres); + let proposal = message("pending-sqs-save", "We should use SQS."); + return { + state: applyInference(state, { + id: "pending-sqs-save-event", + type: "settle.suggested", + threadId: "queue-thread", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: proposal.ts, + source: { + messageId: proposal.id, + author: proposal.author as ConversationPlan.SourceAuthor, + quote: proposal.text, + start: 0, + end: proposal.text.length, + role: "resolution", + }, + optionId: "sqs-option", + }, proposal), + proposal, + }; +} diff --git a/apps/server/src/conversation-plan/pipeline-provenance-3.test.ts b/apps/server/src/conversation-plan/pipeline-provenance-3.test.ts new file mode 100644 index 00000000..9e61b970 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-provenance-3.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { JevAnswer } from "./jev"; +import { optionIdFor, planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { seeded, seededOptionId, settledBy, withOption } from "./interpret.test-fixtures"; +import { confidentChoice, first, follow, member, message } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import { queueWithPendingSave } from "./pipeline-ordinary-save-pending.test-fixtures"; + +test("specific pending agreement corroborates support without changing the role gate", () => { + let current = message("pending-assent", "Sounds good to me.", "Jules"); + let answers = { + ...follow({ role: "support", thread: "thread-a" }), + support: { type: "noul" as const, noul: 0.62 }, + agrees_with_settle: { type: "noul" as const, noul: 0.71 }, + }; + let run = (state: ConversationPlan.State) => + planEvents({ + channelId: "channel", + message: current, + state, + first: first({ support: 0.39, c0_owned_unretracted: 0.92 }), + candidates: [{ quote: current.text, start: 0, end: current.text.length, answers }], + }); + expect(run(settledBy(seeded(), "Mina")).events.map(event => event.type)) + .toEqual(["stance.changed", "settle.agreed"]); + expect(run(withOption(seeded())).events).toEqual([]); + let pending = settledBy(seeded(), "Mina"); + let otherId = optionIdFor("channel", "other-pending-test", 0, 1000); + pending.threads[0]!.contributions.push({ + ...pending.threads[0]!.contributions[0]!, + id: otherId, + text: "Use another option.", + }); + let conflicting = planEvents({ + channelId: "channel", + message: current, + state: pending, + first: first({ c0_owned_unretracted: 0.92 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: { + ...answers, + option: { + type: "choice", + choice: otherId, + confidence: 0.95, + probabilities: { [otherId]: 0.95, [seededOptionId]: 0.05 }, + }, + }, + }], + }); + expect(conflicting.events).toEqual([]); + let self = planEvents({ + channelId: "channel", + message: { ...current, author: member("Mina") }, + state: pending, + first: first({ c0_owned_unretracted: 0.92 }), + candidates: [{ quote: current.text, start: 0, end: current.text.length, answers }], + }); + expect(self.events).toEqual([]); +}); + +test("withdrawal needs the own accepted pending option and cannot become generic opposition", () => { + let { state } = queueWithPendingSave(); + let assess = ( + candidateState = state, + handle = "Mina", + text = "I take back my SQS preference for now.", + overrides: Record = {}, + owned = 0.95, + ) => { + let current = message(`withdraw-${handle}`, text, handle); + let quote = extractQuotes(text)[0]!; + return planEvents({ + channelId: "channel", + message: current, + state: candidateState, + first: first({ c0_owned_unretracted: owned, correction: 0.91, withdrawal: 0.92 }), + candidates: [{ + ...quote, + answers: { + ...follow({ role: "objection", thread: "queue-thread" }), + option: confidentChoice("sqs-option"), + withdraws_pending_settle: { type: "noul", noul: 0.95 }, + ...overrides, + }, + }], + }); + }; + expect(assess().events.map(event => event.type)).toEqual(["stance.changed"]); + expect(assess(state, "Jules").events).toEqual([]); + expect(assess(state, "Mina", undefined, { option: confidentChoice("postgres-option") }).events) + .toEqual([]); + expect(assess(state, "Mina", undefined, { thread: confidentChoice("none") }).events) + .toEqual([]); + expect( + assess(state, "Mina", undefined, { + withdraws_pending_settle: { type: "noul", noul: 0.2 }, + }).events, + ).toEqual([]); + let absent = stateWithOption( + "Which queue should handle background jobs?", + "queue-thread", + "sqs-option", + "Use SQS.", + ); + expect(assess(absent).events).toEqual([]); + expect(assess(state, "Mina", "If I take back my SQS preference, revisit this.").events) + .toEqual([]); + expect(assess(state, "Mina", "I take back my SQS preference if launch slips.").events) + .toEqual([]); + expect(assess(state, "Mina", "Alice said I take back my SQS preference.", {}, 0.1).events) + .toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-provenance-4.test.ts b/apps/server/src/conversation-plan/pipeline-provenance-4.test.ts new file mode 100644 index 00000000..bc3bce1e --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-provenance-4.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import { effectivePending } from "./preference"; +import { planEvents } from "./policy"; +import { buildCandidateTargetingRequest, buildTriageRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { queueWithPendingSave } from "./pipeline-ordinary-save-pending.test-fixtures"; + +test("a direct proposal withdrawal becomes one sourced neutral stance, leaving another clause eligible", () => { + let { state, proposal } = queueWithPendingSave(); + let current = message( + "withdraw-sqs", + "I take back my SQS preference for now. Let's verify backups before choosing.", + "Mina", + ); + let quotes = extractQuotes(current.text); + expect(quotes).toHaveLength(2); + let triage = buildTriageRequest(current, [proposal], state.threads, quotes, state.events); + expect(triage.questions.withdrawal?.type).toBe("noul"); + let targeting = buildCandidateTargetingRequest( + current, + [proposal], + state.threads, + quotes, + 0, + state.events, + ); + expect(targeting.questions.c0_withdraws_pending_settle?.type).toBe("noul"); + expect(targeting.questions.c0_material_objection).toBeUndefined(); + expect(targeting.questions.c0_duplicate).toBeUndefined(); + expect(Object.keys(targeting.questions)).toHaveLength(14); + let firstAnswers = first({ withdrawal: 0.92, correction: 0.91 }); + let withdrawal = { + ...quotes[0]!, + answers: { + ...follow({ role: "objection", thread: "queue-thread" }), + option: confidentChoice("sqs-option"), + withdraws_pending_settle: { type: "noul" as const, noul: 0.94 }, + }, + }; + let result = planEvents({ + channelId: "channel", + message: current, + state, + first: firstAnswers, + candidates: [withdrawal, { + ...quotes[1]!, + answers: { + ...follow({ role: "constraint", thread: "queue-thread" }), + option: confidentChoice("sqs-option"), + relation: confidentChoice("qualifies"), + duplicate: { type: "noul" as const, noul: 0.1 }, + }, + }], + }); + expect(result.events.map(event => event.type)).toEqual([ + "stance.changed", + "constraint.added", + "settle.deferred", + ]); + expect(result.events[0]).toMatchObject({ + threadId: "queue-thread", + optionId: "sqs-option", + position: "neutral", + source: { ...quotes[0], role: "withdrawal" }, + }); + expect(result.events[1]).toMatchObject({ source: { ...quotes[1], role: "constraint" } }); + let proposalEvent = state.events.findLast(event => + event.type === "settle.suggested" && event.threadId === "queue-thread" + ); + expect(result.events[2]).toMatchObject({ + type: "settle.deferred", + threadId: "queue-thread", + proposalId: proposalEvent?.id, + source: { ...quotes[1], role: "constraint" }, + }); + let neutral = result.events[0] as Extract; + let after = result.events.reduce( + (next, event) => applyInference(next, event, current), + state, + ); + expect(effectivePending(after.threads[0]!, after.events)).toBeUndefined(); + expect(after.threads[0]!.pendingSettle).toEqual(state.threads[0]!.pendingSettle); + expect(after.threads[0]!.decisionHistory).toEqual(state.threads[0]!.decisionHistory); + expect(() => + applyInference(state, { + ...neutral, + source: { ...neutral.source, role: "objection" }, + }, current) + ).toThrow("stance source role disagrees"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recommendation-2.test.ts b/apps/server/src/conversation-plan/pipeline-recommendation-2.test.ts new file mode 100644 index 00000000..052c0dab --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recommendation-2.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import type { JevQuestion } from "./jev"; +import { buildTargetingRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { seeded, seededOptionId, settledBy, withOption } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; + +test("agreement targeting is requested only when a proposal is pending", () => { + let current = message("assent", "Sounds good to me.", "alice"); + let candidates = [{ quote: current.text, start: 0, end: current.text.length }]; + let without = buildTargetingRequest(current, [], withOption(seeded()).threads, candidates); + let withProposal = buildTargetingRequest( + current, + [], + settledBy(seeded(), "bob").threads, + candidates, + ); + expect(without.questions.c0_agrees_with_settle).toBeUndefined(); + expect(withProposal.questions.c0_agrees_with_settle).toMatchObject({ + type: "noul", + criteria: { + true: "The speaker agrees with the pending proposal to settle.", + false: "No agreement with a pending proposal.", + }, + }); + expect( + (withProposal.state as { threads: Array<{ pendingSettle?: unknown }> }).threads[0] + .pendingSettle, + ).toEqual({ + optionId: seededOptionId, + option: "Use an optional outline.", + proposer: "bob", + }); +}); + +test("multiple pending proposals keep assent targeting explicitly ambiguous", () => { + let state = settledBy(seeded(), "bob"); + let other = structuredClone(state.threads[0]!); + other.id = "thread-b"; + other.question = "Should we add an optional sidebar?"; + other.contributions[0]!.id = "sidebar-option"; + other.contributions[0]!.text = "Add an optional sidebar."; + other.pendingSettle = { + optionId: "sidebar-option", + proposer: "cara", + messageId: "sidebar-proposal", + }; + state.threads.push(other); + let current = message( + "ambiguous-assent", + "Sounds good to me. What about agent access?", + "alice", + ); + let request = buildTargetingRequest(current, [], state.threads, extractQuotes(current.text)); + let target = request.questions.c0_thread as Extract; + expect(target.criteria["thread-a"]).toContain("Use an optional outline."); + expect(target.criteria["thread-b"]).toContain("Add an optional sidebar."); + let agreement = request.questions.c0_agrees_with_settle; + expect(agreement?.instructions).toContain("If several proposals are pending"); + expect(agreement?.instructions).not.toContain("the pending proposal by bob"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recommendation-6.test.ts b/apps/server/src/conversation-plan/pipeline-recommendation-6.test.ts new file mode 100644 index 00000000..59ecb855 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recommendation-6.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import { planEvents } from "./policy"; +import { seeded, seededOptionId } from "./interpret.test-fixtures"; +import { first, follow, message } from "./policy-initial.test-fixtures"; + +test("two distinct supporters produce tentative leaning, not a decision", () => { + let state = seeded(); + let proposal = message("option", "Use an optional outline."); + state = applyInference(state, { + id: "option-event", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: 1, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: proposal.id, + author: proposal.author as ConversationPlan.SourceAuthor, + quote: proposal.text, + start: 0, + end: proposal.text.length, + role: "option", + }, + contribution: { + id: seededOptionId, + text: proposal.text, + authoring: "quoted", + targetId: "thread-a", + }, + }, proposal); + let firstSupport = message("first-support", "I support the optional outline.", "Theo"); + state = applyInference(state, { + id: "first-stance", + type: "stance.changed", + scopedProposalId: null, + threadId: "thread-a", + observedThreadVersion: 2, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: firstSupport.id, + author: firstSupport.author as ConversationPlan.SourceAuthor, + quote: firstSupport.text, + start: 0, + end: firstSupport.text.length, + role: "support", + }, + optionId: seededOptionId, + position: "support", + }, firstSupport); + let current = message("second-support", "I also support the optional outline.", "Imani"); + let answers = { + ...follow({ role: "support", thread: "thread-a" }), + option: { + type: "choice", + choice: seededOptionId, + confidence: 0.95, + probabilities: { [seededOptionId]: 0.95, none: 0.05 }, + }, + }; + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: first({ support: 0.95 }), + candidates: [{ + quote: current.text, + start: 0, + end: current.text.length, + answers: answers as any, + }], + }); + expect(output.events.map((event) => event.type)).toEqual(["stance.changed", "thread.leaning"]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-recovery-ownership.test.ts b/apps/server/src/conversation-plan/pipeline-recovery-ownership.test.ts new file mode 100644 index 00000000..68ec2b3f --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-recovery-ownership.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test"; +import { initialState } from "./domain"; +import { planEvents } from "./policy"; +import { buildTriageRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { mockResult } from "./interpret.test-fixtures"; +import { first, follow, message } from "./policy-initial.test-fixtures"; + +test.each([ + { + name: "an unrelated opening question with no option evidence", + text: "Who owns authentication? Use Auth0. Use GitHub auth.", + role: "question", + newOption: 0.05, + expected: ["thread.opened"], + }, + { + name: "a generic concern even with a noisy option score", + text: "I'm worried about login usability. Use Auth0. Use GitHub auth.", + role: "objection", + newOption: 0.95, + expected: [], + }, +])("multi-option grouping rejects $name", ({ text, role, newOption, expected }) => { + let current = message("unrelated-group", text, "Jules"); + let quotes = extractQuotes(current.text); + let triage = mockResult(buildTriageRequest(current, [], [], quotes).questions, { + new_question: 0.97, + new_option: 0.95, + act: "question", + thread_target: "new", + significance: 2, + }).answers; + let opening = follow({ role, thread: "new" }); + opening.new_option = { type: "noul", noul: newOption }; + let output = planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: triage, + candidates: quotes.map((quote, index) => ({ + ...quote, + answers: index === 0 ? opening : follow({ role: "option", thread: "new" }), + })), + }); + expect(output.events.map(event => event.type)).toEqual([...expected]); + expect(output.events.some(event => event.type === "option.added")).toBe(false); + if (role === "question") { + expect(output.events[0]).toMatchObject({ question: quotes[0]!.quote }); + } +}); + +test("an unowned opening quote cannot bootstrap a multi-option thread", () => { + let current = message( + "reported-options", + "Should we use Auth0? Or roll our own? Or use GitHub auth?", + "Jules", + ); + let quotes = extractQuotes(current.text); + let output = planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: { + ...first({ new_question: 0.95, c0_owned_unretracted: 0.2 }), + act: { + type: "choice", + choice: "question", + confidence: 0.95, + probabilities: { question: 0.95, none: 0.05 }, + }, + thread_target: { + type: "choice", + choice: "new", + confidence: 0.95, + probabilities: { new: 0.95, none: 0.05 }, + }, + }, + candidates: quotes.map((quote, index) => ({ + ...quote, + answers: follow({ role: "option", thread: index ? "none" : "new" }), + })), + }); + expect(output.events).toEqual([]); + expect(output.outcomes[0]?.gate).toBe("source ownership unclear"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-reply-dense.test.ts b/apps/server/src/conversation-plan/pipeline-reply-dense.test.ts new file mode 100644 index 00000000..ecdf919f --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-reply-dense.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { askJev, type JevQuestion } from "./jev"; +import { + buildCandidateTargetingRequest, + buildTargetingRequest, + buildTriageRequest, +} from "./questions"; +import { extractQuotes } from "./quotes"; +import { mockResult, seeded, withOption } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; +import { reply } from "./pipeline-reply.test-fixtures"; + +test("twelve populated targets fit Jev's state budget with three long quotes", async () => { + let template = withOption(seeded()).threads[0]; + let threads: ConversationPlan.Thread[] = Array.from({ length: 12 }, (_, index) => { + let options = Array.from({ length: 8 }, (_, optionIndex) => ({ + ...template.contributions[0], + id: `option-${index}-${optionIndex}-${"x".repeat(12)}`, + text: "o".repeat(500), + })); + let reasons = Array.from({ length: 12 }, (_, reasonIndex) => ({ + ...template.contributions[0], + id: `reason-${index}-${reasonIndex}-${"x".repeat(12)}`, + kind: "reason" as const, + text: "r".repeat(500), + targetId: options[0].id, + sources: [{ ...template.contributions[0].sources[0], role: "reason" as const }], + })); + return { + ...template, + id: `thread-${index}`, + question: "Q".repeat(500), + status: index === 11 ? "discarded" : "exploring", + contributions: [...options, ...reasons], + pendingSettle: index === 0 + ? { optionId: options[0].id, proposer: "Mina", messageId: "proposal" } + : undefined, + stances: Array.from({ length: 20 }, (_, stanceIndex) => ({ + id: `stance-${index}-${stanceIndex}`, + participant: `member-${stanceIndex}`, + optionId: options[0].id, + position: "support" as const, + sources: [{ ...template.contributions[0].sources[0], role: "support" as const }], + at: 1000, + })), + }; + }); + let current = message( + "dense", + ["A", "B", "C"].map((letter) => `Consider ${letter} ${"x".repeat(1230)}.`).join(" "), + ); + let recent = Array.from( + { length: 12 }, + (_, index) => message(`recent-${index}`, "r".repeat(300)), + ); + let candidates = extractQuotes(current.text); + expect(candidates).toHaveLength(3); + let triage = buildTriageRequest(current, recent, threads); + let targeting = buildTargetingRequest(current, recent, threads, candidates); + let isolated = buildCandidateTargetingRequest(current, recent, threads, candidates, 0); + expect((isolated.state as { recent: Array<{ id: string }> }).recent.map(entry => entry.id)) + .toEqual(recent.map(entry => entry.id)); + let context = (targeting.state as { + threads: Array<{ + id: string; + options: Array<{ id: string }>; + pendingSettle?: { optionId: string; option: string; proposer: string }; + }>; + }).threads; + let targetChoices = + (targeting.questions.c0_thread as Extract).criteria; + let optionChoices = + (targeting.questions.c0_chosen_option as Extract).criteria; + expect(context.map((thread) => thread.id)).toEqual( + Object.keys(targetChoices).filter((id) => id !== "new" && id !== "none"), + ); + expect(context.flatMap((thread) => thread.options.map((option) => option.id)).sort()).toEqual( + Object.keys(optionChoices).filter((id) => id !== "new" && id !== "none").sort(), + ); + expect(context[0].pendingSettle).toEqual({ + optionId: threads[0].pendingSettle!.optionId, + option: "o".repeat(100), + proposer: "Mina", + }); + expect(targeting.questions.c0_agrees_with_settle?.type).toBe("noul"); + expect(Object.keys(targeting.questions)).toHaveLength(42); + expect(Object.keys(targeting.questions).filter(key => key.endsWith("_duplicate"))) + .toEqual([]); + expect(context[11].options.length).toBeGreaterThan(0); + for (let request of [triage, targeting]) { + expect((request.state as { threads: unknown[] }).threads).toHaveLength(12); + expect(JSON.stringify(request.state).length).toBeLessThanOrEqual(24_000); + let answer = await askJev(request, { + apiKey: "test-only", + fetch: async () => reply(mockResult(request.questions, {}).answers), + }); + expect(answer.model).toBe("jev-1.13.0"); + } +}); diff --git a/apps/server/src/conversation-plan/pipeline-reply-limits.test.ts b/apps/server/src/conversation-plan/pipeline-reply-limits.test.ts new file mode 100644 index 00000000..2d51e742 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-reply-limits.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { initialState, restoreState } from "./domain"; +import { askJev } from "./jev"; +import { buildTargetingRequest, QUESTION_SET_VERSION } from "./questions"; +import { extractQuotes } from "./quotes"; +import { mockResult, seeded } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; +import { reply } from "./pipeline-reply.test-fixtures"; + +test("keeps the expanded three-quote request within a 45-question bound", async () => { + let questions = Object.fromEntries( + Array.from({ length: 46 }, (_, index) => [ + `q${index}`, + { type: "noul" as const, instructions: "Is this true?" }, + ]), + ); + let calls = 0; + let ask = (count: number) => { + let selected = Object.fromEntries(Object.entries(questions).slice(0, count)); + return askJev({ state: {}, questions: selected }, { + apiKey: "test-only", + fetch: async () => { + calls++; + return reply(mockResult(selected, {}).answers); + }, + }); + }; + expect(Object.keys((await ask(45)).answers)).toHaveLength(45); + expect(calls).toBe(1); + await expect(ask(46)).rejects.toThrow("invalid Jev request bounds"); + expect(calls).toBe(1); +}); + +test("three discarded-thread quotes fit the request and persisted answer bounds", async () => { + let current = message("three-quotes", "Revisit A. Revisit B. Revisit C."); + let threads = [{ ...seeded().threads[0], status: "discarded" as const }]; + let request = buildTargetingRequest(current, [], threads, extractQuotes(current.text)); + expect(Object.keys(request.questions)).toHaveLength(39); + expect(Object.keys(request.questions).filter(key => key.endsWith("_duplicate"))).toEqual([]); + let result = await askJev(request, { + apiKey: "test-only", + fetch: async () => reply(mockResult(request.questions, {}).answers), + }); + expect(Object.keys(result.answers)).toHaveLength(39); + let state = initialState(); + state.analysis.push({ + messageId: current.id, + questionSetVersion: QUESTION_SET_VERSION, + modelVersion: result.model, + status: "unlinked", + eventIds: [], + passes: [{ stage: "targeting", answers: result.answers }], + }); + expect(restoreState(state).analysis[0].passes[0].answers).toEqual(result.answers); + let oversized = structuredClone(state); + for (let index = 0; index < 7; index++) { + oversized.analysis[0].passes[0].answers[`extra${index}`] = { + type: "noul", + noul: 0.5, + }; + } + expect(() => restoreState(oversized)).toThrow("too many analysis questions"); +}); diff --git a/apps/server/src/conversation-plan/pipeline-reply-named.test.ts b/apps/server/src/conversation-plan/pipeline-reply-named.test.ts new file mode 100644 index 00000000..14853a61 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-reply-named.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from "bun:test"; +import { initialState } from "./domain"; +import { planEvents } from "./policy"; +import { buildTriageRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { mockResult } from "./interpret.test-fixtures"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; + +test.each([ + { + name: "When I Work", + text: "We could use When I Work, Deputy, or Homebase.", + options: ["When I Work", "Deputy", "Homebase"], + }, + { + name: "Asana", + text: "We could use Asana, Linear, or Trello.", + options: ["Asana", "Linear", "Trello"], + }, +])("a named three-option list can start with $name", ({ name, text, options }) => { + let current = message(`named-options-${name}`, text, "Jules"); + let candidates = extractQuotes(text); + expect(candidates.map(candidate => candidate.quote)).toEqual([...options]); + let result = planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: { + ...first({ new_question: 0.97, new_option: 0.99 }), + act: confidentChoice("question"), + thread_target: confidentChoice("new"), + significance: { + type: "score", + score: 2, + confidence: 0.95, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0.05, "2": 0.9, "3": 0.05 }, + }, + }, + candidates: candidates.map(candidate => ({ + ...candidate, + answers: { + ...follow({ role: "option", thread: "new" }), + option: confidentChoice("new"), + chosen_option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.99 }, + planning_substance: { type: "noul", noul: 0.99 }, + duplicate: { type: "noul", noul: 0.01 }, + }, + })), + }); + expect(result.events.map(event => event.type)).toEqual([ + "thread.opened", + "option.added", + "option.added", + "option.added", + ]); + expect( + result.events.slice(1).map(event => + event.type === "option.added" ? event.contribution.text : "" + ), + ).toEqual([...options]); +}); + +test("multi-option grouping refuses a question longer than the event text bound", () => { + let current = message( + "long-options", + `${"Background ".repeat(51)}Use Auth0. Use GitHub auth.`, + ); + let quotes = ["Use Auth0.", "Use GitHub auth."]; + let candidates = quotes.map(quote => ({ + quote, + start: current.text.indexOf(quote), + end: current.text.indexOf(quote) + quote.length, + answers: follow({ role: "option", thread: "none" }), + })); + expect(current.text.length).toBeGreaterThan(500); + let triage = mockResult(buildTriageRequest(current, [], []).questions, { + new_question: 0.97, + act: "question", + thread_target: "new", + }).answers; + expect( + planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: triage, + candidates, + }).events, + ).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-reply.test-fixtures.ts b/apps/server/src/conversation-plan/pipeline-reply.test-fixtures.ts new file mode 100644 index 00000000..bd8240e9 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-reply.test-fixtures.ts @@ -0,0 +1,25 @@ +import type { JevQuestion } from "./jev"; + +export let question: Record = { + flag: { type: "noul", instructions: "Does current.text ask?" }, + act: { type: "choice", instructions: "What act?", criteria: { yes: "Yes", no: "No" } }, + level: { type: "score", instructions: "How much?", criteria: ["None", "Some", "All"] }, +}; + +export let validAnswers = { + flag: { type: "noul", noul: 0.8 }, + act: { type: "choice", choice: "yes", confidence: 0.9, probabilities: { yes: 0.9, no: 0.1 } }, + level: { + type: "score", + score: 1.8, + confidence: 0.8, + legend: { "0": "None", "1": "Some", "2": "All" }, + probabilities: { "0": 0.1, "1": 0, "2": 0.9 }, + }, +}; + +export let reply = (answers: unknown) => + new Response( + JSON.stringify({ model: "jev-1.13.0", answers, usage: { input_tokens: 10, output_tokens: 2 } }), + { status: 200 }, + ); diff --git a/apps/server/src/conversation-plan/pipeline-scoped.test-fixtures.ts b/apps/server/src/conversation-plan/pipeline-scoped.test-fixtures.ts new file mode 100644 index 00000000..4d3a69ee --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-scoped.test-fixtures.ts @@ -0,0 +1,154 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import type { JevAnswer } from "./jev"; +import { applyInference } from "./domain"; +import { planEvents, type PolicyInput } from "./policy"; +import { extractQuotes } from "./quotes"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { d01LinkedEditorCard } from "./pipeline-linked-option.test-fixtures"; + +export function d01SeedScopedChoiceProposal( + setup: ReturnType, + proposalId = "d01-m6-scoped-proposal", + messageId = "d01-m6", +) { + let lexicalId = "01M3QAQWN9TYWMFW3D0EYAZY8H"; + let quote = "I'd pick Lexical for the spike;"; + let sourceMessage = message( + messageId, + `agreed. ${quote} still want to see how it handles pasted tables.`, + "Mei", + ); + let proposal: ConversationPlan.Event = { + id: proposalId, + type: "scoped-choice.proposed", + threadId: setup.threadId, + observedThreadVersion: setup.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: sourceMessage.ts, + source: { + messageId: sourceMessage.id, + author: sourceMessage.author as ConversationPlan.SourceAuthor, + quote, + start: 8, + end: 39, + role: "support", + }, + cardId: "01M3QAQ8HM8DVYA9E4N28QCQ7T", + optionId: lexicalId, + label: "Lexical", + scope: "spike", + }; + let state = applyInference(setup.state, proposal, sourceMessage); + return { ...setup, state, proposal, lexicalId }; +} + +export function d01RobM7PolicyResult( + setup: ReturnType, + options: { + state?: ConversationPlan.State; + author?: string; + text?: string; + owned?: number; + optionId?: string; + messageId?: string; + } = {}, +) { + let threadId = setup.threadId; + let selectedOptionId = options.optionId ?? "01M3QAQWN9TYWMFW3D0EYAZY8H"; + let text = options.text ?? "yep, Lexical for the spike. not a final library call yet."; + let current = { + ...message(options.messageId ?? "d01-m7", text, options.author ?? "Rob"), + ts: 1001, + }; + let quotes = extractQuotes(text); + let firstPass: Record = { + ...first({ new_question: 0.14, enough_purpose: 0.93, new_option: 0.33, support: 0.93 }), + c0_owned_unretracted: { type: "noul", noul: options.owned ?? 0.89 }, + c1_owned_unretracted: { type: "noul", noul: 0.93 }, + act: { + type: "choice" as const, + choice: "commitment", + confidence: 0.4, + probabilities: { + question: 0, + proposal: 0.06, + evaluation: 0.17, + commitment: 0.5, + correction: 0.23, + other: 0.04, + }, + }, + thread_target: { + type: "choice" as const, + choice: threadId, + confidence: 0.92, + probabilities: { [threadId]: 0.95, new: 0, none: 0.05 }, + }, + }; + let optionProbabilities = Object.fromEntries( + setup.options.map(option => [option.id, option.id === selectedOptionId ? 0.54 : 0]), + ); + optionProbabilities.none = 0.46; + let chosenProbabilities = Object.fromEntries( + setup.options.map(option => [option.id, option.id === selectedOptionId ? 0.63 : 0]), + ); + chosenProbabilities.none = 0.37; + let recordedChoices: Record = { + option: { + type: "choice", + choice: selectedOptionId, + confidence: 0.46, + probabilities: optionProbabilities, + }, + chosen_option: { + type: "choice", + choice: selectedOptionId, + confidence: 0.56, + probabilities: chosenProbabilities, + }, + }; + let candidates: PolicyInput["candidates"] = quotes.map((quote, index) => ({ + ...quote, + answers: index === 0 + ? { + ...follow({ role: "support", thread: threadId }), + ...recordedChoices, + role: { + type: "choice", + choice: "support", + confidence: 0.59, + probabilities: { + question: 0, + option: 0.16, + reason: 0, + constraint: 0, + support: 0.63, + objection: 0, + resolution: 0.08, + reopening: 0, + none: 0.13, + }, + }, + thread: { + type: "choice", + choice: threadId, + confidence: 0.99, + probabilities: { [threadId]: 1, new: 0, none: 0 }, + }, + relation: confidentChoice("supports"), + support: { type: "noul", noul: 0.93 }, + duplicate: { type: "noul", noul: 0.64 }, + } + : { ...follow({ role: "none", thread: threadId }) }, + })); + let result = planEvents({ + channelId: "channel", + message: current, + state: options.state ?? setup.state, + linkedCards: setup.linkedCards, + first: firstPass, + candidates, + }); + return { current, quotes, result }; +} diff --git a/apps/server/src/conversation-plan/pipeline-source-quotes.test.ts b/apps/server/src/conversation-plan/pipeline-source-quotes.test.ts new file mode 100644 index 00000000..d8cc4131 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-source-quotes.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from "bun:test"; +import { buildCandidateTargetingRequest, buildTriageRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { message } from "./policy-initial.test-fixtures"; + +test("extracts two exact clauses with UTF-16 offsets", () => { + let text = "Keep the outline optional; separately, should each repository remember my choice?"; + let quotes = extractQuotes(text); + expect(quotes).toHaveLength(2); + for (let quote of quotes) expect(text.slice(quote.start, quote.end)).toBe(quote.quote); +}); + +test("splits one sentence with two imperative claims into source-bound clauses", () => { + let text = "Keep setup optional and remember my choice per repository."; + let quotes = extractQuotes(text); + expect(quotes.map((item) => item.quote)).toEqual([ + "Keep setup optional", + "remember my choice per repository.", + ]); + for (let quote of quotes) expect(text.slice(quote.start, quote.end)).toBe(quote.quote); +}); + +test("splits short assent from an and-keep condition with exact offsets", () => { + let text = "yes, and keep managed hosting as the escape hatch if on-call gets silly"; + let quotes = extractQuotes(text); + expect(quotes.map(item => item.quote)).toEqual([ + "yes,", + "keep managed hosting as the escape hatch if on-call gets silly", + ]); + for (let quote of quotes) expect(text.slice(quote.start, quote.end)).toBe(quote.quote); + expect(quotes[1]!.start).toBe(text.indexOf("keep")); +}); + +test.each( + [ + [ + "reported library choice", + "We could do tiptap or prosemirror or lexical for text string handling.", + ["tiptap", "prosemirror", "lexical"], + ], + [ + "held-out storage choice", + "We could use SQLite, Postgres, or DynamoDB for storing drafts.", + ["SQLite", "Postgres", "DynamoDB"], + ], + [ + "named topic", + "By agent access I mean Copilot or a hosted gateway or bring-your-own API keys.", + ["Copilot", "a hosted gateway", "bring-your-own API keys"], + ], + ] as const, +)("extracts atomic, exact alternatives from %s", (_, text, labels) => { + let current = message("three-way", text); + let quotes = extractQuotes(text); + expect(quotes.map(item => item.quote)).toEqual([...labels]); + for (let quote of quotes) expect(text.slice(quote.start, quote.end)).toBe(quote.quote); + let triage = buildTriageRequest(current, [], [], quotes); + let state = triage.state as { + current: { text: string }; + candidates: Array<{ quote: string; start: number; end: number }>; + }; + expect(state.current.text).toBe(text); + expect(state.candidates).toEqual(quotes); + for (let index = 0; index < quotes.length; index++) { + let targeting = buildCandidateTargetingRequest(current, [], [], quotes, index).state as { + current: { text: string }; + preceding: string; + }; + expect(targeting.current.text).toBe(quotes[index]!.quote); + expect(targeting.preceding).toBe(text.slice(0, quotes[index]!.start)); + } +}); + +test("extracts D03's three provider alternatives as exact UTF-16 spans", () => { + let text = "what sends transactional notifications? our SMTP relay, Postmark, or SES?"; + expect(extractQuotes(text)).toEqual([ + { quote: "SMTP relay", start: 44, end: 54 }, + { quote: "Postmark", start: 56, end: 64 }, + { quote: "SES", start: 69, end: 72 }, + ]); +}); + +test.each([ + "what sends transactional notifications when configured? our SMTP relay, Postmark, or SES?", + "what sends transactional notifications as a fallback? our SMTP relay, Postmark, or SES?", +])("keeps qualified question text together for review: %s", text => { + expect(extractQuotes(text).map(({ quote }) => quote)).toEqual([ + text.slice(0, text.indexOf("?") + 1), + text.slice(text.indexOf("?") + 2), + ]); +}); + +test.each([ + "We could use a cache or logs or metrics and improve visibility.", + "The report says we could use SQLite or Postgres or DynamoDB for drafts.", + 'We could use "SQLite or Postgres or DynamoDB" for drafts.', + "We could use SQLite or Postgres or DynamoDB, but none has been approved.", + "We could use SQLite or Postgres or DynamoDB but none has been approved.", + "We could use SQLite or Postgres or DynamoDB none has been approved.", + "We could use SQLite or Postgres or DynamoDB or Redis for drafts.", + "We could use SQLite or SQLite or DynamoDB for drafts.", +])("does not split an unsafe or non-atomic list: %s", text => { + expect(extractQuotes(text)).toHaveLength(1); +}); + +test("keeps an unpunctuated contrastive suffix out of option sources", () => { + let text = "We could use SQLite or Postgres or DynamoDB but none has been approved."; + expect(extractQuotes(text)).toEqual([{ quote: text, start: 0, end: text.length }]); +}); + +test.each([ + "We could use SQLite or Postgres or DynamoDB because latency matters.", + "We could use SQLite or Postgres or DynamoDB since latency matters.", + "We could use SQLite or Postgres or DynamoDB as latency matters.", + "We could use SQLite or Postgres or DynamoDB for storing drafts because latency matters.", +])("keeps shared rationale out of the last alternative: %s", text => { + expect(extractQuotes(text)).toEqual([{ quote: text, start: 0, end: text.length }]); +}); diff --git a/apps/server/src/conversation-plan/pipeline-source-recovery.test.ts b/apps/server/src/conversation-plan/pipeline-source-recovery.test.ts new file mode 100644 index 00000000..947f9729 --- /dev/null +++ b/apps/server/src/conversation-plan/pipeline-source-recovery.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; +import { seeded } from "./interpret.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; +import { d01RecordedOpening } from "./policy-terminal.test-fixtures"; + +test("D01 question recovery keeps purpose, source, and target guards", () => { + let input = d01RecordedOpening(); + let firstClause = input.candidates[0]!; + let purposeOnly = { + ...input, + message: { ...input.message, text: firstClause.quote }, + candidates: [firstClause], + }; + let unowned = structuredClone(input); + unowned.first.c1_owned_unretracted = { type: "noul", noul: 0.1 }; + let wrongQuote = structuredClone(input); + wrongQuote.candidates[1]!.quote = "what else are we comparing?"; + let weakQuestion = structuredClone(input); + weakQuestion.first.new_question = { type: "noul", noul: 0.1 }; + let reported = message("d01-reported", `Rob said, "${input.message.text}"`, "Nia"); + let reportedQuestion = { + ...input, + message: reported, + candidates: extractQuotes(reported.text).map((quote, index) => ({ + ...quote, + answers: input.candidates[index]!.answers, + })), + }; + let competingThread = { ...input, state: seeded() }; + for ( + let guarded of [ + purposeOnly, + unowned, + wrongQuote, + weakQuestion, + reportedQuestion, + competingThread, + ] + ) { + expect(planEvents(guarded).events).toEqual([]); + } +}); + +test("D01 recovery does not turn a confident first-clause resolution into a question", () => { + let input = d01RecordedOpening(); + let current = { + ...input.message, + text: "need to pick Lexical before comments get any deeper. what are we comparing?", + }; + let candidates = extractQuotes(current.text).map((quote, index) => ({ + ...quote, + answers: structuredClone(input.candidates[index]!.answers), + })); + candidates[0]!.answers.role = { + type: "choice", + choice: "resolution", + confidence: 0.95, + probabilities: { resolution: 0.95, question: 0.01, none: 0.04 }, + }; + expect(planEvents({ ...input, message: current, candidates }).events).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/policy-D19-fourth-candidate.test.ts b/apps/server/src/conversation-plan/policy-D19-fourth-candidate.test.ts new file mode 100644 index 00000000..f84f481e --- /dev/null +++ b/apps/server/src/conversation-plan/policy-D19-fourth-candidate.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import type { JevAnswer } from "./jev"; +import { planEvents } from "./policy"; +import { extractQuotes } from "./quotes"; + +let text = + "For human sign-in, the options are GitHub OAuth or email magic links. Separately, for the hosted agent’s repository credentials, we could pass each user’s GitHub token or use a GitHub App installation token. Those are two different calls."; +let current: Chat.Entry = { + id: "D19-m2", + text, + author: { kind: "member", handle: "Omar" }, + ts: 1_001, +}; +let threadId = "human-sign-in"; + +function choice(value: string): JevAnswer { + return { + type: "choice", + choice: value, + confidence: 0.95, + probabilities: { [value]: 0.95, none: 0.05 }, + }; +} + +function openThread(): ConversationPlan.State { + let opening: Chat.Entry = { + id: "m1-human", + text: "How should people sign in?", + author: { kind: "member", handle: "Leah" }, + ts: 1_000, + }; + let state = applyInference(initialState(), { + id: "open-human-sign-in", + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: opening.ts, + source: { + messageId: opening.id, + author: opening.author as ConversationPlan.SourceAuthor, + quote: opening.text, + start: 0, + end: opening.text.length, + role: "question", + }, + question: opening.text, + }, opening); + let agentQuestion: Chat.Entry = { + id: "m1-agent", + text: "How should the hosted agent get repository credentials?", + author: { kind: "member", handle: "Leah" }, + ts: 1_000, + }; + return applyInference(state, { + id: "open-agent-credentials", + type: "thread.opened", + threadId: "agent-credentials", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: agentQuestion.ts, + source: { + messageId: agentQuestion.id, + author: agentQuestion.author as ConversationPlan.SourceAuthor, + quote: agentQuestion.text, + start: 0, + end: agentQuestion.text.length, + role: "question", + }, + question: agentQuestion.text, + }, agentQuestion); +} + +test("policy accepts the fourth D19 option candidate instead of truncating at three", () => { + let quotes = extractQuotes(text); + let first = { + new_question: { type: "noul" as const, noul: 0.05 }, + new_option: { type: "noul" as const, noul: 0.95 }, + explicit_resolution: { type: "noul" as const, noul: 0.05 }, + act: choice("proposal"), + thread_target: choice(threadId), + ...Object.fromEntries(quotes.map((_, index) => [ + `c${index}_owned_unretracted`, + { type: "noul" as const, noul: 0.95 }, + ])), + }; + let candidates = quotes.map((quote, index) => ({ + ...quote, + answers: { + role: choice("option"), + thread: choice(index < 2 ? threadId : "agent-credentials"), + option: choice("new"), + chosen_option: choice("new"), + new_option: { type: "noul" as const, noul: 0.95 }, + planning_substance: { type: "noul" as const, noul: 0.95 }, + duplicate: { type: "noul" as const, noul: 0.05 }, + support: { type: "noul" as const, noul: 0.05 }, + }, + })); + let planned = planEvents({ + channelId: "channel", + message: current, + state: openThread(), + first, + candidates, + }); + + expect( + planned.events.flatMap(event => + event.type === "option.added" + ? [{ + quote: event.source?.quote, + start: event.source?.start, + end: event.source?.end, + }] + : [] + ), + ).toEqual(quotes); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-agreement.test.ts b/apps/server/src/conversation-plan/policy-candidate-agreement.test.ts new file mode 100644 index 00000000..6b66fdc6 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-agreement.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { + ordinaryFrame, + ordinaryInput, + withdrawCurrent, +} from "./policy-candidate-ordinary.test-fixtures"; +import { runAgreement } from "./policy-candidate-agreement"; + +test.each([0.7, 0.699])( + "agreement threshold %s returns progression even without a proposal", + probability => { + let input = ordinaryInput("none"); + input.candidates[0]!.answers.agrees_with_settle = { type: "noul", noul: probability }; + let { context, entry, role } = ordinaryFrame(input); + expect(role.role).toBe("none"); + let result = runAgreement(context, entry, role); + expect(result).toBeDefined(); + if (probability === 0.7) { + expect(result?.proposed).toMatchObject({ type: "settle.agreed", optionId: "alpha" }); + expect(context.selectedTarget).toBe("provider"); + expect(entry.outcome.targetId).toBe("provider"); + } else { + expect(result?.proposed).toBeUndefined(); + expect(context.selectedTarget).toBeUndefined(); + } + expect(context.events).toHaveLength(0); + }, +); +test("agreement recomputes effective pending from current working", () => { + let input = ordinaryInput("none"); + let { context, entry, role } = ordinaryFrame(input); + context.working = withdrawCurrent(input); + expect(role.pending).toBeDefined(); + expect(runAgreement(context, entry, role)).toEqual({ proposed: undefined }); + expect(context.selectedTarget).toBeUndefined(); +}); +test("agreement retains the raw named option despite low classifier confidence", () => { + let input = ordinaryInput("none"); + input.candidates[0]!.answers.option = { + type: "choice", + choice: "beta", + confidence: 0.4, + probabilities: { beta: 0.4, alpha: 0.35, none: 0.25 }, + }; + let { context, entry, role } = ordinaryFrame(input); + expect(role.option).toBeUndefined(); + expect(runAgreement(context, entry, role)).toEqual({ proposed: undefined }); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-agreement.ts b/apps/server/src/conversation-plan/policy-candidate-agreement.ts new file mode 100644 index 00000000..f7aaab05 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-agreement.ts @@ -0,0 +1,47 @@ +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import type { Event } from "./policy-types"; +import { noul } from "./policy-scoring"; +import { effectivePending } from "./preference"; + +/** Invoke only for its matching captured role; undefined skips, a result continues without application. */ +export function runAgreement( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { message } = context; + let { candidate, outcome } = entry; + let { thread } = frame; + let selectedTarget = context.selectedTarget; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + let proposed: Event | undefined = undefined; + try { + let pending = thread && effectivePending(thread, working.events); + let namedOption = candidate.answers.option?.type === "choice" + ? candidate.answers.option.choice + : undefined; + if ( + thread && pending && message.author.kind === "member" + && message.author.handle !== pending.proposer + && (!namedOption || namedOption === "none" || namedOption === pending.optionId) + && noul(candidate.answers, "agrees_with_settle") >= 0.7 + ) { + proposed = { + ...base(thread.id, "settle.agreed"), + type: "settle.agreed", + source: source("support"), + optionId: pending.optionId, + }; + selectedTarget = thread.id; + outcome.targetId = thread.id; + } + + return { proposed }; + } finally { + context.selectedTarget = selectedTarget; + } +} diff --git a/apps/server/src/conversation-plan/policy-candidate-application-deferral.test.ts b/apps/server/src/conversation-plan/policy-candidate-application-deferral.test.ts new file mode 100644 index 00000000..46389a54 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-application-deferral.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { applyCandidateEvent } from "./policy-candidate-application"; +import { + applicationFrame, + batchEvents, + deferralInput, +} from "./policy-candidate-application.test-fixtures"; +import { effectivePending } from "./preference"; +import { replay } from "./domain"; +import { applyInference } from "./domain"; +import type { Event } from "./policy-types"; + +// Replay-valid histories; same-message neutral publications explicitly seed the batch. +test("constraint can defer the raw pending choice after its proposer withdraws", () => { + let { input, neutral } = deferralInput(); + expect(input.state.threads[0]!.pendingSettle).toBeDefined(); + expect(effectivePending(input.state.threads[0]!, input.state.events)).toBeUndefined(); + let frame = applicationFrame(input, [neutral]); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual([ + "stance.changed", + "constraint.added", + "settle.deferred", + ]); + expect(frame.entry.outcome.eventIds).toHaveLength(2); + expect(replay(frame.context.working.events)).toEqual(frame.context.working); +}); +test.each([["Jules", false, "Let's verify backups before choosing."], [ + "Mina", + true, + "Let's verify backups before choosing.", +], ["Jules", true, "Let's verify backups."]])( + "deferral retains participant, same-message and cue requirements: %s/%s/%s", + (handle, sameMessage, quote) => { + let { input, neutral } = deferralInput(handle, sameMessage, quote); + let frame = applicationFrame(input, [neutral]); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual([ + "stance.changed", + "constraint.added", + ]); + }, +); +test("an existing current deferral is not repeated", () => { + let { input, neutral } = deferralInput(); + let deferred: Event = { + id: "already-deferred", + type: "settle.deferred", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + proposalId: "settle", + source: { ...neutral.source, role: "constraint" }, + }; + input.state = applyInference(input.state, deferred, input.message); + let frame = applicationFrame(input, [neutral]); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual([ + "stance.changed", + "constraint.added", + ]); + expect(replay(frame.context.working.events)).toEqual(frame.context.working); +}); +test("synthetic eleven-event batch reaches thirteen because constraint deferral has no support cap", () => { + let { input, neutral } = deferralInput(); + let frame = applicationFrame(input, [...batchEvents(neutral, 10), neutral]); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events).toHaveLength(13); + expect(frame.context.events.slice(11).map(item => item.type)).toEqual([ + "constraint.added", + "settle.deferred", + ]); + expect(frame.entry.outcome.status).toBe("accepted"); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-application-failures.test.ts b/apps/server/src/conversation-plan/policy-candidate-application-failures.test.ts new file mode 100644 index 00000000..79ebe4e9 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-application-failures.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; +import { applyCandidateEvent } from "./policy-candidate-application"; +import { applicationFrame, supportInput } from "./policy-candidate-application.test-fixtures"; +import { decidedInput } from "./policy-candidate-stance.test-fixtures"; + +test("main inference rejection keeps the original state and publications", () => { + let frame = applicationFrame(supportInput([])), before = frame.context.working; + frame.proposed.observedThreadVersion++; + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.working).toBe(before); + expect(frame.context.events).toHaveLength(0); + expect(frame.entry.outcome.eventIds).toHaveLength(0); + expect(frame.entry.outcome.gate).toBe("domain rejected proposed event"); +}); + +test("a rejected reopening proposal retains the review created before application", () => { + let frame = applicationFrame(decidedInput()); + frame.proposed.observedThreadVersion++; + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.reviews).toHaveLength(1); + expect(frame.context.events).toHaveLength(0); + expect(frame.entry.outcome.status).toBe("review"); + expect(frame.entry.outcome.gate).toBe("domain rejected proposed event"); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-application.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-application.test-fixtures.ts new file mode 100644 index 00000000..dd56a471 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-application.test-fixtures.ts @@ -0,0 +1,91 @@ +import { expect } from "bun:test"; +import { applyInference } from "./domain"; +import { + contributionFrame, + contributionInput, +} from "./policy-candidate-contribution.test-fixtures"; +import { runContribution } from "./policy-candidate-contribution"; +import { runStance } from "./policy-candidate-stance"; +import { stanceInput } from "./policy-candidate-stance.test-fixtures"; +import { pendingChoice } from "./policy-candidate-scoped.test-fixtures"; +import { message } from "./policy-initial.test-fixtures"; +import type { Event, PolicyInput } from "./policy-types"; + +// Offline application fixtures for archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// policy.ts whole try/catch1634–1726. Full policy callbacks remain assigned whole. +export function supportInput( + participants: string[] = ["Jules"], + pending = false, + quote = "I support Alpha.", +) { + let input = stanceInput("support", quote); + for (let [index, handle] of participants.entries()) { + let saved = message(`prior-support-${index}`, "I support Alpha.", handle); + input.state = applyInference( + input.state, + stanceEvent(input, saved, `prior-support-${index}`, "support"), + saved, + ); + } + if (pending) pendingChoice(input); + return input; +} +export function stanceEvent( + input: PolicyInput, + saved: PolicyInput["message"], + id: string, + position: "support" | "neutral", +): Extract { + if (saved.author.kind !== "member") throw new Error("fixture requires a member source"); + return { + id, + type: "stance.changed", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: saved.id, + author: saved.author, + quote: saved.text, + start: 0, + end: saved.text.length, + role: position === "neutral" ? "withdrawal" : "support", + }, + optionId: "alpha", + scopedProposalId: null, + position, + }; +} +export function deferralInput( + handle = "Jules", + sameMessage = true, + quote = "Let's verify backups before choosing.", +) { + let input = contributionInput("constraint", quote); + input.message.author = { kind: "member", handle }; + pendingChoice(input); + let saved = sameMessage + ? input.message + : message("older-withdrawal", "I withdraw that choice.", handle); + let neutral = stanceEvent(input, saved, "same-batch-neutral", "neutral"); + input.state = applyInference(input.state, neutral, saved); + return { input, neutral }; +} +export function applicationFrame(input = supportInput(), batch: Event[] = []) { + let result = contributionFrame(input); + result.context.events.push(...batch); + let proposed = result.role.role === "constraint" + ? runContribution(result.context, result.entry, result.role)?.proposed + : runStance(result.context, result.entry, result.role)?.proposed; + expect(proposed).toBeDefined(); + if (!proposed) { + throw new Error("application requires a definite proposal after the original guard"); + } + return { ...result, proposed }; +} +export function batchEvents(event: Event, count: number): Event[] { + // Synthetic publication-batch boundary; these entries do not claim a replay history. + return Array.from({ length: count }, (_, index) => ({ ...event, id: `batch-${index}` })); +} diff --git a/apps/server/src/conversation-plan/policy-candidate-application.test.ts b/apps/server/src/conversation-plan/policy-candidate-application.test.ts new file mode 100644 index 00000000..166eadf2 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-application.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test"; +import { applyCandidateEvent } from "./policy-candidate-application"; +import { + applicationFrame, + batchEvents, + supportInput, +} from "./policy-candidate-application.test-fixtures"; +import { replay } from "./domain"; +import { decidedInput, rawChoice } from "./policy-candidate-stance.test-fixtures"; + +// Exact archived application statement1634–1726; full-policy callbacks remain deferred. +test("main inference publishes before accepting and preserves an existing gate", () => { + let frame = applicationFrame(supportInput([])); + frame.entry.outcome.gate = "retained gate"; + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual(["stance.changed"]); + expect(frame.entry.outcome.status).toBe("accepted"); + expect(frame.entry.outcome.gate).toBe("retained gate"); + expect(replay(frame.context.working.events)).toEqual(frame.context.working); +}); +test.each([[[], 1], [["Jules"], 2], [["Mina", "Mina"], 1]] as const)( + "distinct supporters %j yield %i published events", + (participants, count) => { + let frame = applicationFrame(supportInput([...participants])); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events).toHaveLength(count); + expect(frame.context.working.threads[0]!.status).toBe(count === 2 ? "leaning" : "exploring"); + expect(frame.entry.outcome.gate).toBe("accepted"); + expect(replay(frame.context.working.events)).toEqual(frame.context.working); + }, +); +test("leaning and assent use successive working versions and the retained current reference", () => { + let frame = applicationFrame(supportInput(["Jules"], true)); + let version = frame.context.working.threads[0]!.version; + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual([ + "stance.changed", + "thread.leaning", + "settle.agreed", + ]); + expect(frame.context.events.map(item => item.observedThreadVersion)).toEqual([ + version, + version + 1, + version + 2, + ]); + expect(frame.entry.outcome.eventIds).toEqual(frame.context.events.map(item => item.id)); + expect(replay(frame.context.working.events)).toEqual(frame.context.working); +}); +test.each([[10, ["stance.changed", "thread.leaning"]], [11, ["stance.changed"]]] as const)( + "synthetic batch of %i preserves each original follow-up cap", + (count, types) => { + let frame = applicationFrame(supportInput(["Jules"], true)); + frame.context.events.push(...batchEvents(frame.proposed, count)); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.slice(count).map(item => item.type)).toEqual([...types]); + expect(frame.context.events).toHaveLength(12); + expect(frame.entry.outcome.status).toBe("accepted"); + }, +); +test.each([[0.699, false], [0.7, true]] as const)( + "raw assent score %f preserves threshold", + (score, agreed) => { + let input = supportInput([], true, "This seems sensible."); + input.candidates[0]!.answers.option = rawChoice("none"); + input.candidates[0]!.answers.chosen_option = rawChoice("none"); + input.candidates[0]!.answers.agrees_with_settle = { type: "noul", noul: score }; + let frame = applicationFrame(input); + expect(frame.proposed.type).toBe("stance.changed"); + if (frame.proposed.type !== "stance.changed") throw new Error("expected stance"); + expect(frame.proposed.optionId).toBeUndefined(); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.some(item => item.type === "settle.agreed")).toBe(agreed); + }, +); +test("a conflicting raw option blocks assent despite a high agreement score", () => { + let input = supportInput([], true, "This seems sensible."); + input.candidates[0]!.answers.option = rawChoice("beta"); + input.candidates[0]!.answers.chosen_option = rawChoice("none"); + input.candidates[0]!.answers.agrees_with_settle = { type: "noul", noul: 0.95 }; + let frame = applicationFrame(input); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual(["stance.changed"]); +}); +test("captured option permits bare assent after the raw answer changes to none", () => { + let input = supportInput([], true, "yes"); + let frame = applicationFrame(input); + frame.role.option = "alpha"; // Explicit synthetic capture control; no reclassification. + input.candidates[0]!.answers.option = rawChoice("none"); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual(["stance.changed", "settle.agreed"]); +}); +test("the proposer cannot create their own agreement", () => { + let input = supportInput([], true); + input.message.author = { kind: "member", handle: "Jules" }; + let frame = applicationFrame(input); + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.context.events.map(item => item.type)).toEqual(["stance.changed"]); +}); + +test("reopening proposals remain review outcomes and retain their existing review record", () => { + let frame = applicationFrame(decidedInput()); + expect(frame.proposed.type).toBe("candidate.proposed"); + expect(frame.context.reviews).toHaveLength(1); + frame.entry.outcome.gate = "no useful role"; + applyCandidateEvent(frame.context, frame.entry, frame.role, frame.proposed); + expect(frame.entry.outcome.status).toBe("review"); + expect(frame.entry.outcome.gate).toBe("review"); + expect(frame.context.reviews).toHaveLength(1); + expect(frame.context.events.map(item => item.type)).toEqual(["candidate.proposed"]); + expect(replay(frame.context.working.events)).toEqual(frame.context.working); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-application.ts b/apps/server/src/conversation-plan/policy-candidate-application.ts new file mode 100644 index 00000000..69154b81 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-application.ts @@ -0,0 +1,119 @@ +import { applyInference } from "./domain"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import { noul } from "./policy-scoring"; +import type { Event } from "./policy-types"; +import { activeSettleDeferral, effectivePending } from "./preference"; + +/** Invoke after the original no-proposal guard; the driver retains its own event cap. */ +export function applyCandidateEvent( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, + proposed: Event, +): void { + let { message, events } = context; + let { candidate, outcome } = entry; + let { option } = frame; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + try { + try { + working = applyInference(working, proposed, message); + events.push(proposed); + outcome.eventIds.push(proposed.id); + outcome.status = proposed.type === "candidate.proposed" ? "review" : "accepted"; + if (outcome.gate === "no useful role") outcome.gate = outcome.status; + let participant = message.author.kind === "member" ? message.author.handle : undefined; + if ( + proposed.type === "constraint.added" && participant + && /\b(?:verify|check|test)\b.*\bbefore\b.*\b(?:choos|decid|sav|settl)/i + .test(candidate.quote) + && events.some(item => + item.type === "stance.changed" && item.threadId === proposed.threadId + && item.source.messageId === message.id && item.position === "neutral" + && item.source.author.kind === "member" + && item.source.author.handle === participant + ) + ) { + let proposer = participant; + let current = working.threads.find(item => item.id === proposed.threadId); + let pending = current?.pendingSettle; + let proposal = pending + && working.events.findLast(item => + item.type === "settle.suggested" && item.threadId === proposed.threadId + && item.optionId === pending.optionId + && item.source.messageId === pending.messageId + ); + if ( + current && pending && proposal?.type === "settle.suggested" + && pending.proposer === proposer + && !activeSettleDeferral(current, working.events) + ) { + let deferred: Event = { + ...base(current.id, "settle.deferred"), + type: "settle.deferred", + proposalId: proposal.id, + source: source("constraint"), + }; + working = applyInference(working, deferred, message); + events.push(deferred); + outcome.eventIds.push(deferred.id); + } + } + if ( + proposed.type === "stance.changed" && proposed.position === "support" + && events.length < 12 + ) { + let current = working.threads.find((item) => item.id === proposed.threadId); + let supporters = proposed.optionId && new Set( + current?.stances.filter((item) => + item.optionId === proposed.optionId && item.position === "support" + ).map((item) => item.participant), + ); + if (current?.status === "exploring" && supporters && supporters.size >= 2) { + let leaning: Event = { + ...base(current.id, "thread.leaning"), + type: "thread.leaning", + source: proposed.source, + optionId: proposed.optionId, + }; + working = applyInference(working, leaning, message); + events.push(leaning); + outcome.eventIds.push(leaning.id); + } + let pending = current && effectivePending(current, working.events); + let namedOption = candidate.answers.option?.type === "choice" + ? candidate.answers.option.choice + : undefined; + if ( + current && pending && message.author.kind === "member" + && message.author.handle !== pending.proposer + && (!namedOption || namedOption === "none" || namedOption === pending.optionId) + && (proposed.optionId === pending.optionId + || option === pending.optionId + && /^(?:yes|yep|agreed|i agree)[,!.]?$/iu.test(candidate.quote.trim()) + || noul(candidate.answers, "agrees_with_settle") >= 0.7) + && events.length < 12 + ) { + let agreed: Event = { + ...base(current.id, "settle.agreed"), + type: "settle.agreed", + source: proposed.source, + optionId: pending.optionId, + }; + working = applyInference(working, agreed, message); + events.push(agreed); + outcome.eventIds.push(agreed.id); + } + } + } catch { + outcome.status = "review"; + outcome.gate = "domain rejected proposed event"; + } + } finally { + context.working = working; + } +} diff --git a/apps/server/src/conversation-plan/policy-candidate-contribution.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-contribution.test-fixtures.ts new file mode 100644 index 00000000..8bf6de00 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-contribution.test-fixtures.ts @@ -0,0 +1,51 @@ +import { expect } from "bun:test"; +import { applyEvent } from "./events"; +import { beginCandidate } from "./policy-candidate-entry"; +import { candidateContext, roleInput } from "./policy-candidate-entry.test-fixtures"; +import { captureCandidateRole } from "./policy-candidate-role"; +import { cardId, pendingChoice } from "./policy-candidate-scoped.test-fixtures"; +import { confidentChoice } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import type { PolicyInput } from "./policy-types"; + +// Offline fixtures for archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/policy.ts consequent1370–1431. +// Complete downstream-dependent callbacks remain assigned whole. +export function contributionInput( + role = "reason", + quote = "Batched delivery reduces latency.", +): PolicyInput { + let input = roleInput(role, quote); + input.state = stateWithOption("Which provider?", "provider", "alpha", "Alpha"); + return input; +} +export function linkContributionCard(input: PolicyInput) { + input.state = applyEvent(input.state, { + id: "card-linked", + type: "card.linked", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + questionnaireId: cardId, + }); + input.linkedCards = new Map([["provider", { + cardId, + options: [{ id: "alpha", label: "Alpha" }], + }]]); +} +export function qualifiedInput() { + let input = contributionInput("constraint", "This requires a retry budget."); + pendingChoice(input); + input.candidates[0]!.answers.qualifies_pending_settle = { type: "noul", noul: 0.8 }; + input.candidates[0]!.answers.relation = confidentChoice("qualifies"); + return input; +} +export function contributionFrame(input = contributionInput()) { + let context = candidateContext(input); + let entry = beginCandidate(context, 0, input.candidates[0]!)!; + let role = captureCandidateRole(context, entry)!; + expect(role).toBeDefined(); + return { context, entry, role }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-contribution.test.ts b/apps/server/src/conversation-plan/policy-candidate-contribution.test.ts new file mode 100644 index 00000000..03e76294 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-contribution.test.ts @@ -0,0 +1,191 @@ +import { expect, test } from "bun:test"; +import { applyInference, replay } from "./domain"; +import { + contributionFrame, + contributionInput, + linkContributionCard, + qualifiedInput, +} from "./policy-candidate-contribution.test-fixtures"; +import { runContribution } from "./policy-candidate-contribution"; +import { confidentChoice } from "./policy-initial.test-fixtures"; +import { withdrawCurrent } from "./policy-candidate-ordinary.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import { optionIdFor, stableId } from "./policy-identity"; + +test.each(["option", "reason", "constraint"])( + "%s preserves exact quoted proposal without application", + roleName => { + let input = contributionInput(roleName); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = contributionFrame(input); + let before = structuredClone(context.working); + expect(role.role).toBe(roleName); + let result = runContribution(context, entry, role); + expect(result?.proposed).toMatchObject({ + type: roleName + ".added", + source: { quote: entry.candidate.quote, role: roleName }, + contribution: { + text: entry.candidate.quote, + authoring: "quoted", + targetId: "alpha", + relation: "supports", + }, + }); + let proposed = result!.proposed!; + expect("contribution" in proposed && proposed.contribution.id).toBe( + roleName === "option" + ? optionIdFor(input.channelId, input.message.id, 0, input.message.ts) + : stableId(input.channelId, input.message.id, 0, "contribution"), + ); + let accepted = applyInference(input.state, proposed, input.message); + expect(replay(accepted.events)).toEqual(accepted); + expect(context.working).toEqual(before); + expect(context.events).toHaveLength(0); + expect(context.reviews).toHaveLength(0); + expect(entry.outcome.eventIds).toHaveLength(0); + }, +); +test.each([ + "Alpha, Beta, or Gamma", + "Which provider fits?", + "Alpha is quick but Beta is flexible.", +])("unsafe option quote stays usable as a reason/constraint: %s", quote => { + for (let name of ["option", "reason", "constraint"]) { + let { context, entry, role } = contributionFrame(contributionInput(name, quote)); + let result = runContribution(context, entry, role); + if (name === "option") { + expect(result).toBeUndefined(); + expect(entry.outcome.status).toBe("review"); + expect(entry.outcome.gate).toBe( + "option source contains a question, group, or contrastive evaluation", + ); + } else { + let expected: "reason.added" | "constraint.added" = name === "reason" + ? "reason.added" + : "constraint.added"; + expect(result?.proposed?.type).toBe(expected); + } + } +}); +test.each([0.799, 0.8])( + "duplicate threshold %s preserves ignored outcome on refusal", + probability => { + let input = contributionInput(); + input.candidates[0]!.answers.duplicate = { type: "noul", noul: probability }; + let { context, entry, role } = contributionFrame(input); + let result = runContribution(context, entry, role); + if (probability === 0.8) { + expect(result).toBeUndefined(); + expect(entry.outcome.gate).toBe("duplicate contribution"); + expect(entry.outcome.status).toBe("ignored"); + } else expect(result?.proposed?.type).toBe("reason.added"); + }, +); +test("cached option group exempts the duplicate guard", () => { + let { context, entry, role } = contributionFrame(); + entry.candidate.answers.duplicate = { type: "noul", noul: 1 }; + context.candidateRun!.optionGroup = "provider"; + expect(runContribution(context, entry, role)?.proposed?.type).toBe("reason.added"); + expect(context.events).toHaveLength(0); +}); +test("missing captured thread is handled without inventing an event", () => { + let input = contributionInput(); + input.candidates[0]!.answers.thread = confidentChoice("missing"); + let { context, entry, role } = contributionFrame(input); + expect(role.thread).toBeUndefined(); + expect(runContribution(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("contribution target needs review"); + expect(context.events).toHaveLength(0); +}); +test.each(["matching", "wrong-card", "ambiguous"])( + "existing option name/card uniqueness: %s", + variant => { + let input = contributionInput("option", "I'd pick Alpha."); + linkContributionCard(input); + if (variant === "wrong-card") input.linkedCards!.get("provider")!.cardId = "replacement"; + if (variant === "ambiguous") { + let card = input.linkedCards!.get("provider")!; + input.linkedCards = new Map([["provider", { + ...card, + options: [...card.options, { id: "other", label: "Alpha" }], + }]]); + } + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = contributionFrame(input); + let result = runContribution(context, entry, role); + if (variant === "matching") { + expect(result).toBeUndefined(); + expect(entry.outcome.gate).toBe("proposal names an existing card option"); + } else expect(result?.proposed?.type).toBe("option.added"); + }, +); +test("fresh card map replacement overrides the earlier captured linked card", () => { + let input = contributionInput("option", "I'd pick Alpha."); + linkContributionCard(input); + let { context, entry, role } = contributionFrame(input); + let captured = role.linkedCard; + context.input.linkedCards = new Map([["provider", { cardId: "replacement", options: [] }]]); + expect(runContribution(context, entry, role)?.proposed?.type).toBe("option.added"); + expect(role.linkedCard).toBe(captured); +}); +test("freshly restored matching card is read after an earlier mismatched capture", () => { + let input = contributionInput("option", "I'd pick Alpha."); + linkContributionCard(input); + let saved = input.linkedCards!.get("provider")!; + input.linkedCards = new Map([["provider", { ...saved, cardId: "stale" }]]); + let { context, entry, role } = contributionFrame(input); + context.input.linkedCards = new Map([["provider", saved]]); + expect(role.linkedCard!.cardId).toBe("stale"); + expect(runContribution(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("proposal names an existing card option"); +}); +test.each(["alpha", "missing", "none"])( + "target option %s uses captured contributions or thread fallback", + option => { + let input = contributionInput(); + input.candidates[0]!.answers.option = confidentChoice(option); + let { context, entry, role } = contributionFrame(input); + let event = runContribution(context, entry, role)!.proposed!; + expect("contribution" in event && event.contribution.targetId).toBe( + option === "alpha" ? "alpha" : "provider", + ); + }, +); +test.each(["supports", "challenges", "qualifies", "blocks"])("relation allowlist: %s", relation => { + let input = contributionInput(); + input.candidates[0]!.answers.relation = confidentChoice(relation); + let { context, entry, role } = contributionFrame(input); + let event = runContribution(context, entry, role)!.proposed!; + expect("contribution" in event && event.contribution.relation).toBe( + relation === "blocks" ? undefined : relation, + ); + if (relation === "blocks") { + expect("contribution" in event && Object.hasOwn(event.contribution, "relation")).toBe(false); + } +}); +test("qualified pending forces its captured option and relation despite later answers", () => { + let input = qualifiedInput(); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = contributionFrame(input); + expect(role.qualifiedPending).toBeTruthy(); + context.working = withdrawCurrent(input); + expect(replay(context.working.events)).toEqual(context.working); + entry.candidate.answers.option = confidentChoice("missing"); + entry.candidate.answers.relation = confidentChoice("challenges"); + let event = runContribution(context, entry, role)!.proposed!; + expect("contribution" in event && event.contribution).toMatchObject({ + targetId: "alpha", + relation: "qualifies", + }); +}); + +test("target selection keeps captured contributions after working-thread replacement", () => { + let { context, entry, role } = contributionFrame(); + let captured = role.thread; + context.working = stateWithOption("Which provider?", "provider", "beta", "Beta"); + expect(replay(context.working.events)).toEqual(context.working); + let event = runContribution(context, entry, role)!.proposed!; + expect("contribution" in event && event.contribution.targetId).toBe("alpha"); + expect(role.thread).toBe(captured); + expect(context.working.threads[0]!.contributions[0]!.id).toBe("beta"); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-contribution.ts b/apps/server/src/conversation-plan/policy-candidate-contribution.ts new file mode 100644 index 00000000..761477a4 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-contribution.ts @@ -0,0 +1,89 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import { COMMA_ALTERNATIVES, namesCardOption } from "./policy-cues"; +import { optionIdFor, stableId } from "./policy-identity"; +import { choice, moderateChoice, noul } from "./policy-scoring"; +import type { Event } from "./policy-types"; + +/** Invoke only for the captured option/reason/constraint role; a result progresses without application. */ +export function runContribution( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { input, channelId, message, first } = context; + let { index, candidate, outcome } = entry; + let { thread, option, pending, qualifiedPending, role } = frame; + let { optionGroup } = context.candidateRun!; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + let proposed: Event | undefined = undefined; + + if ( + role === "option" + && (COMMA_ALTERNATIVES.test(candidate.quote.trim()) + || /^(?:what|which|who|where|when|why|how)\b[^?]*\?$/i.test(candidate.quote.trim()) + || /\b[\p{L}][\p{L}\p{N}-]*\s+is\s+[^.!?]{1,120}\s+but\s+[^.!?]{1,120}[.!?]?$/iu + .test(candidate.quote.trim())) + ) { + outcome.status = "review"; + outcome.gate = "option source contains a question, group, or contrastive evaluation"; + return undefined; + } + if (!thread) { + outcome.status = "review"; + outcome.gate = "contribution target needs review"; + return undefined; + } + if ( + role === "option" && message.author.kind === "member" + && moderateChoice(first, "act", "proposal", 0.7) + && option && !["new", "none"].includes(option) + ) { + let card = input.linkedCards?.get(thread.id); + let named = card && card.cardId === thread.questionnaireId + ? card.options.filter(item => namesCardOption(candidate.quote, item.label)) + : []; + if (named.length === 1 && named[0]?.id === option) { + outcome.status = "review"; + outcome.gate = "proposal names an existing card option"; + return undefined; + } + } + // The new group has no prior contributions; repeated labels were skipped above. + if (!optionGroup && noul(candidate.answers, "duplicate") >= 0.8) { + outcome.gate = "duplicate contribution"; + return undefined; + } + let type = `${role}.added` as "option.added" | "reason.added" | "constraint.added"; + let relation = qualifiedPending && role === "constraint" + ? "qualifies" + : choice(candidate.answers, "relation"); + let existingOption = qualifiedPending && role === "constraint" + ? pending?.optionId + : option && !["new", "none"].includes(option) + && thread.contributions.some((item) => item.id === option && item.kind === "option") + ? option + : undefined; + proposed = { + ...base(thread.id, type), + type, + source: source(role as ConversationPlan.SourceRole), + contribution: { + id: role === "option" + ? optionIdFor(channelId, message.id, index, message.ts) + : stableId(channelId, message.id, index, "contribution"), + text: candidate.quote, + authoring: "quoted", + targetId: existingOption ?? thread.id, + ...(["supports", "challenges", "qualifies"].includes(relation ?? "") + ? { relation: relation as "supports" | "challenges" | "qualifies" } + : {}), + }, + }; + + return { proposed }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-entry.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-entry.test-fixtures.ts new file mode 100644 index 00000000..e8a9cd6e --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-entry.test-fixtures.ts @@ -0,0 +1,38 @@ +import { prepareCandidateRun } from "./policy-candidate-setup"; +import { choiceInput, groupInput, prepareCandidateContext } from "./policy-candidate.test-fixtures"; +import { confidentChoice, follow } from "./policy-initial.test-fixtures"; +import type { PolicyInput } from "./policy-types"; + +// Offline entry/role fixtures for archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// policy.ts statements878–1091. Downstream full-policy callbacks remain assigned. +export function candidateContext(input: PolicyInput = groupInput()) { + let context = prepareCandidateContext(input); + prepareCandidateRun(context); + return context; +} + +export function roleInput(role = "reason", quote = "This reduces latency."): PolicyInput { + let input = choiceInput(["alpha"]); + let span = groupInput([quote]); + input.message = span.message; + input.first.significance = { + type: "score", + score: 2, + confidence: 0.95, + legend: { "0": "chatter", "1": "minor", "2": "useful" }, + probabilities: { "2": 1 }, + }; + input.first.reason = { type: "noul", noul: 0.95 }; + input.candidates[0] = { + ...span.candidates[0]!, + answers: { + ...follow({ role, thread: "provider" }), + option: confidentChoice("alpha"), + chosen_option: confidentChoice("alpha"), + relation: confidentChoice("supports"), + planning_substance: { type: "noul", noul: 0.95 }, + duplicate: { type: "noul", noul: 0.05 }, + }, + }; + return input; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-entry.test.ts b/apps/server/src/conversation-plan/policy-candidate-entry.test.ts new file mode 100644 index 00000000..2cf90c7d --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-entry.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from "bun:test"; +import { beginCandidate } from "./policy-candidate-entry"; +import { candidateContext, roleInput } from "./policy-candidate-entry.test-fixtures"; +import { groupInput } from "./policy-candidate.test-fixtures"; +import { capEvents } from "./policy-terminal.test-fixtures"; +import { MAX_EVENTS } from "./validation-fields"; + +test.each(["mismatched span", "empty quote", "oversized quote"])( + "pushes outcome before rejecting %s", + kind => { + let context = candidateContext(roleInput()); + let candidate = context.input.candidates[0]!; + if (kind === "mismatched span") candidate.start += 1; + if (kind === "empty quote") { + candidate.quote = ""; + candidate.end = candidate.start; + } + if (kind === "oversized quote") { + candidate.quote = "x".repeat(2049); + context.message.text = candidate.quote; + candidate.start = 0; + candidate.end = candidate.quote.length; + } + context.first.c0_owned_unretracted = { type: "noul", noul: 0 }; + expect(beginCandidate(context, 0, candidate)).toBeUndefined(); + expect(context.outcomes).toEqual([{ + start: candidate.start, + end: candidate.end, + status: "ignored", + gate: "invalid source quote", + eventIds: [], + }]); + expect(context.events).toHaveLength(0); + }, +); + +test("ownership rejection follows source validation and keeps its ignored outcome", () => { + let context = candidateContext(roleInput()); + context.first.c0_owned_unretracted = { type: "noul", noul: 0.69 }; + expect(beginCandidate(context, 0, context.input.candidates[0]!)).toBeUndefined(); + expect(context.outcomes[0]!.gate).toBe("source ownership unclear"); +}); + +test("opening updates working and event IDs before consuming the first cached label", () => { + let context = candidateContext(); + let before = structuredClone(context.input); + let entry = beginCandidate(context, 0, context.input.candidates[0]!)!; + expect(entry.outcome).toBe(context.outcomes[0]!); + expect(entry.candidate).toBe(context.input.candidates[0]!); + expect(entry.index).toBe(0); + expect(context.events.map(event => event.type)).toEqual(["thread.opened"]); + expect(entry.outcome.eventIds).toEqual([context.events[0]!.id]); + expect(context.working.threads[0]!.id).toBe(context.candidateRun!.optionGroup!); + expect([...context.candidateRun!.seenOptionLabels]).toEqual(["alpha"]); + expect(context.input).toEqual(before); +}); + +test("domain failure clears the group and keeps later ownership checks active", () => { + let input = groupInput(); + capEvents(input, MAX_EVENTS); + let context = candidateContext(input); + let oldWorking = context.working; + expect(beginCandidate(context, 0, input.candidates[0]!)).toBeUndefined(); + expect(context.working).toBe(oldWorking); + expect(context.candidateRun!.optionGroup).toBeUndefined(); + expect(context.outcomes[0]!.status).toBe("review"); + expect(context.outcomes[0]!.gate).toBe("multi-option question could not be opened"); + context.first.c1_owned_unretracted = { type: "noul", noul: 0 }; + expect(beginCandidate(context, 1, input.candidates[1]!)).toBeUndefined(); + expect(context.outcomes[1]!.gate).toBe("source ownership unclear"); +}); + +test("duplicate cached labels skip while the group opening and consumed labels survive", () => { + let context = candidateContext(); + beginCandidate(context, 0, context.input.candidates[0]!); + context.facts!.optionLabels[1] = "alpha"; + expect(beginCandidate(context, 1, context.input.candidates[1]!)).toBeUndefined(); + expect(context.outcomes[1]!.gate).toBe("duplicate option in new question"); + expect(context.events).toHaveLength(1); + expect([...context.candidateRun!.seenOptionLabels]).toEqual(["alpha"]); +}); + +test("entry progression does not add a twelve-event guard", () => { + let context = candidateContext(roleInput()); + let existing = candidateContext(); + beginCandidate(existing, 0, existing.input.candidates[0]!); + context.events.push(...Array.from({ length: 12 }, () => existing.events[0]!)); + expect(beginCandidate(context, 0, context.input.candidates[0]!)).toBeDefined(); + expect(context.outcomes).toHaveLength(1); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-entry.ts b/apps/server/src/conversation-plan/policy-candidate-entry.ts new file mode 100644 index 00000000..11ec71ff --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-entry.ts @@ -0,0 +1,89 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import type { PolicyContext } from "./policy-context"; +import { OWNED_QUOTE_MIN } from "./policy-cues"; +import { stableId } from "./policy-identity"; +import { noul } from "./policy-scoring"; +import type { CandidateJudgment, Event } from "./policy-types"; + +export type CandidateEntry = { + index: number; + candidate: CandidateJudgment; + outcome: ConversationPlan.CandidateOutcome; +}; + +/** Undefined skips this candidate; setup must already exist on the captured context. */ +export function beginCandidate( + context: PolicyContext, + index: number, + candidate: CandidateJudgment, +): CandidateEntry | undefined { + let { channelId, message, first, events, outcomes } = context; + let { optionLabels } = context.facts!; + let { seenOptionLabels } = context.candidateRun!; + let optionGroup = context.candidateRun!.optionGroup; + let working = context.working; + try { + let outcome: ConversationPlan.CandidateOutcome = { + start: candidate.start, + end: candidate.end, + status: "ignored", + gate: "no useful role", + eventIds: [], + }; + outcomes.push(outcome); + if ( + message.text.slice(candidate.start, candidate.end) !== candidate.quote + || !candidate.quote || candidate.quote.length > 2048 + ) { + outcome.gate = "invalid source quote"; + return undefined; + } + if (!optionGroup && noul(first, `c${index}_owned_unretracted`) < OWNED_QUOTE_MIN) { + outcome.gate = "source ownership unclear"; + return undefined; + } + if (optionGroup && index === 0) { + let opening: Event = { + id: stableId(channelId, message.id, 0, "thread.opened"), + type: "thread.opened", + threadId: optionGroup, + observedThreadVersion: 0, + origin: message.author.kind === "agent" ? "planner" : "classifier", + actor: message.author.kind === "agent" ? { kind: "agent" } : { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "question", + }, + question: message.text, + }; + try { + working = applyInference(working, opening, message); + events.push(opening); + outcome.eventIds.push(opening.id); + } catch { + optionGroup = undefined; + outcome.status = "review"; + outcome.gate = "multi-option question could not be opened"; + return undefined; + } + } + if (optionGroup) { + let label = optionLabels[index]!; + if (seenOptionLabels.has(label)) { + outcome.gate = "duplicate option in new question"; + return undefined; + } + seenOptionLabels.add(label); + } + return { index, candidate, outcome }; + } finally { + context.working = working; + context.candidateRun!.optionGroup = optionGroup; + } +} diff --git a/apps/server/src/conversation-plan/policy-candidate-factories.test.ts b/apps/server/src/conversation-plan/policy-candidate-factories.test.ts new file mode 100644 index 00000000..3517c734 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-factories.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from "bun:test"; +import { applyInference } from "./domain"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import { + verificationFrame, + verificationInput, +} from "./policy-candidate-verification.test-fixtures"; +import { stableId } from "./policy-identity"; + +test.each(["member", "agent"])("factories retain exact %s source and base metadata", author => { + let input = verificationInput(); + if (author === "agent") input.message.author = { kind: "agent" }; + let { context, entry } = verificationFrame(input); + let factories = createCandidateFactories(context, entry, () => context.working); + if (input.message.author.kind === "system") throw new Error("expected source author"); + expect(factories.source("verification")).toEqual({ + messageId: input.message.id, + author: input.message.author, + quote: entry.candidate.quote, + start: entry.candidate.start, + end: entry.candidate.end, + role: "verification", + }); + expect(factories.base("provider", "settle.resumed")).toEqual({ + id: stableId(input.channelId, input.message.id, entry.index, "settle.resumed"), + threadId: "provider", + observedThreadVersion: context.working.threads[0]!.version, + origin: author === "agent" ? "planner" : "classifier", + actor: { kind: author === "agent" ? "agent" : "classifier" }, + at: input.message.ts, + }); + expect(factories.base("missing", "reason.added").observedThreadVersion).toBe(0); +}); + +test("base reads active local working on every call after a real inferred update", () => { + let { context, entry } = verificationFrame(); + let working = context.working; + let reads = 0; + let factories = createCandidateFactories(context, entry, () => { + reads++; + return working; + }); + expect(reads).toBe(0); + factories.source("constraint"); + expect(reads).toBe(0); + let before = factories.base("provider", "constraint.added"); + working = applyInference(working, { + ...before, + type: "constraint.added", + source: factories.source("constraint"), + contribution: { + id: "constraint", + text: entry.candidate.quote, + authoring: "quoted", + targetId: "provider", + }, + }, context.message); + let after = factories.base("provider", "settle.resumed"); + expect(after.observedThreadVersion).toBe(before.observedThreadVersion + 1); + expect(context.working.threads[0]!.version).toBe(before.observedThreadVersion); + expect(reads).toBe(2); +}); + +test("source keeps captured object references while base retains candidate-index identity", () => { + let { context, entry } = verificationFrame(); + entry.index = 2; + let factories = createCandidateFactories(context, entry, () => context.working); + let originalMessage = context.message; + let originalCandidate = entry.candidate; + context.input.message = { ...originalMessage, id: "replacement" }; + context.input.candidates[0] = { ...originalCandidate, quote: "replacement" }; + originalCandidate.quote = "Captured object changed."; + originalMessage.ts = 1001; + expect(factories.source("reason").quote).toBe("Captured object changed."); + if (originalMessage.author.kind === "system") throw new Error("expected source author"); + expect(factories.source("reason").author).toBe(originalMessage.author); + expect(factories.source("reason").messageId).toBe(originalMessage.id); + expect(factories.base("provider", "reason.added").id).toBe( + stableId("channel", originalMessage.id, 2, "reason.added"), + ); + expect(factories.base("provider", "reason.added").at).toBe(1001); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-factories.ts b/apps/server/src/conversation-plan/policy-candidate-factories.ts new file mode 100644 index 00000000..e6366562 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-factories.ts @@ -0,0 +1,38 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import type { PolicyContext } from "./policy-context"; +import { stableId } from "./policy-identity"; +import type { State } from "./policy-types"; + +/** Bind readWorking to the active handler's local working, never a finished handler or snapshot. */ +export function createCandidateFactories( + context: PolicyContext, + entry: CandidateEntry, + readWorking: () => State, +) { + let { channelId, message } = context; + let { index, candidate } = entry; + let source = (sourceRole: ConversationPlan.SourceRole): ConversationPlan.SourceRef => ({ + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + role: sourceRole, + }); + return { + source, + base: (threadId: string, type: string): ConversationPlan.EventBase => { + let working = readWorking(); + let base = (threadId: string, type: string): ConversationPlan.EventBase => ({ + id: stableId(channelId, message.id, index, type), + threadId, + observedThreadVersion: working.threads.find((item) => item.id === threadId)?.version ?? 0, + origin: message.author.kind === "agent" ? "planner" : "classifier", + actor: message.author.kind === "agent" ? { kind: "agent" } : { kind: "classifier" }, + at: message.ts, + }); + return base(threadId, type); + }, + }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-new-choice.test.ts b/apps/server/src/conversation-plan/policy-candidate-new-choice.test.ts new file mode 100644 index 00000000..800b112f --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-new-choice.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { replay } from "./domain"; +import { runDirectNewChoice } from "./policy-candidate-new-choice"; +import { + newChoiceInput, + pendingChoice, + scopedFrame, +} from "./policy-candidate-scoped.test-fixtures"; +import { effectivePending } from "./preference"; +import { optionIdFor } from "./policy-identity"; + +test.each([false, true])( + "direct new choice stages the exact option with competing=%s", + competing => { + let input = newChoiceInput(); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = scopedFrame(input); + if (competing) context.candidateRun!.competingMessageTargets.add("provider"); + let before = structuredClone(input); + let version = context.working.threads[0]!.version; + expect(runDirectNewChoice(context, entry, role)).toBeUndefined(); + expect(context.events.map(event => event.type)).toEqual( + competing ? ["option.added"] : ["option.added", "settle.suggested"], + ); + let option = context.events[0]!; + if (option.type !== "option.added") throw new Error("expected option"); + expect(option.contribution.id).toBe( + optionIdFor("channel", input.message.id, 0, input.message.ts), + ); + expect(option.observedThreadVersion).toBe(version); + if (!competing) expect(context.events[1]!.observedThreadVersion).toBe(version + 1); + expect(entry.outcome.status).toBe(competing ? "review" : "accepted"); + expect(entry.outcome.gate).toBe(competing ? "competing choice needs review" : "accepted"); + expect(input).toEqual(before); + expect(replay(context.working.events)).toEqual(context.working); + }, +); + +test.each([false, true])( + "effective pending choice controls the skip after withdrawn=%s", + withdrawn => { + let input = newChoiceInput(); + pendingChoice(input, withdrawn); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = scopedFrame(input); + expect(role.thread!.pendingSettle).toBeDefined(); + expect(Boolean(effectivePending(role.thread!, context.working.events))).toBe(!withdrawn); + expect(runDirectNewChoice(context, entry, role)).toBeUndefined(); + expect(context.events).toHaveLength(withdrawn ? 2 : 0); + expect(entry.outcome.gate).toBe(withdrawn ? "accepted" : "competing choice needs review"); + }, +); + +// Batch-length controls do not claim replay consistency for repeated synthetic prior events. +test.each([10, 11])("preserves the original <=10 handler bound at %s prior batch events", count => { + let { context, entry, role } = scopedFrame(newChoiceInput()); + context.events.push(...Array.from({ length: count }, () => context.working.events[0]!)); + let result = runDirectNewChoice(context, entry, role); + expect(result).toBe(count === 10 ? undefined : true); + expect(context.events).toHaveLength(count === 10 ? 12 : 11); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-new-choice.ts b/apps/server/src/conversation-plan/policy-candidate-new-choice.ts new file mode 100644 index 00000000..2054c53a --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-new-choice.ts @@ -0,0 +1,73 @@ +import { applyInference } from "./domain"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import { optionIdFor } from "./policy-identity"; +import type { Event } from "./policy-types"; +import { effectivePending } from "./preference"; + +/** Requires both scoped handlers to progress. Undefined handles/skips; true reaches later roles. */ +export function runDirectNewChoice( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): true | undefined { + let { channelId, message, events } = context; + let { index, candidate, outcome } = entry; + let { thread, directNewChoice } = frame; + let { competingMessageTargets } = context.candidateRun!; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + try { + if (directNewChoice && thread && events.length <= 10) { + if (effectivePending(thread, working.events)) { + outcome.status = "review"; + outcome.gate = "competing choice needs review"; + return undefined; + } + let optionId = optionIdFor(channelId, message.id, index, message.ts); + let competing = competingMessageTargets.has(thread.id); + let added: Event = { + ...base(thread.id, "option.added"), + type: "option.added", + source: source("option"), + contribution: { + id: optionId, + text: candidate.quote, + authoring: "quoted", + targetId: thread.id, + }, + }; + try { + let staged = applyInference(working, added, message); + if (!competing) { + let stagedThread = staged.threads.find(item => item.id === thread.id)!; + let suggested: Event = { + ...base(thread.id, "settle.suggested"), + type: "settle.suggested", + observedThreadVersion: stagedThread.version, + source: source("resolution"), + optionId, + }; + staged = applyInference(staged, suggested, message); + events.push(added, suggested); + outcome.eventIds.push(added.id, suggested.id); + } else { + events.push(added); + outcome.eventIds.push(added.id); + } + working = staged; + outcome.status = competing ? "review" : "accepted"; + outcome.gate = competing ? "competing choice needs review" : "accepted"; + } catch { + outcome.status = "review"; + outcome.gate = "new choice could not be suggested"; + } + return undefined; + } + return true; + } finally { + context.working = working; + } +} diff --git a/apps/server/src/conversation-plan/policy-candidate-ordinary.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-ordinary.test-fixtures.ts new file mode 100644 index 00000000..b0bf57e2 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-ordinary.test-fixtures.ts @@ -0,0 +1,68 @@ +import { expect } from "bun:test"; +import { applyInference } from "./domain"; +import { beginCandidate } from "./policy-candidate-entry"; +import { candidateContext, roleInput } from "./policy-candidate-entry.test-fixtures"; +import { captureCandidateRole } from "./policy-candidate-role"; +import { pendingChoice } from "./policy-candidate-scoped.test-fixtures"; +import { confidentChoice, message } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import type { PolicyInput } from "./policy-types"; + +// Offline fixtures for archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/policy.ts consequents1298–1368. +// Complete downstream-dependent callbacks remain assigned to the full policy. +export function ordinaryInput( + role: "withdrawal" | "none" | "question" = "withdrawal", +): PolicyInput { + let input = roleInput( + role, + role === "withdrawal" + ? "I withdraw that choice." + : role === "none" + ? "I agree with that choice." + : "Should retries survive a restart?", + ); + input.state = stateWithOption("Which provider?", "provider", "alpha", "Alpha"); + input.first.new_question = { type: "noul", noul: 0.6 }; + input.first.withdrawal = { type: "noul", noul: 0.95 }; + input.candidates[0]!.answers.withdraws_pending_settle = { type: "noul", noul: 0.8 }; + input.candidates[0]!.answers.agrees_with_settle = { type: "noul", noul: 0.7 }; + if (role !== "question") { + pendingChoice(input); + if (role === "withdrawal") input.message.author = { kind: "member", handle: "Jules" }; + } else { + input.first.thread_target = confidentChoice("new"); + input.candidates[0]!.answers.thread = confidentChoice("new"); + } + return input; +} +export function ordinaryFrame(input = ordinaryInput()) { + let context = candidateContext(input); + let entry = beginCandidate(context, 0, input.candidates[0]!)!; + let role = captureCandidateRole(context, entry)!; + expect(role).toBeDefined(); + return { context, entry, role }; +} +export function withdrawCurrent(input: PolicyInput) { + let saved = message("later-withdrawal", "I withdraw that choice.", "Jules"); + return applyInference(input.state, { + id: "later-withdrawal", + type: "stance.changed", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: saved.id, + author: { kind: "member", handle: "Jules" }, + quote: saved.text, + start: 0, + end: saved.text.length, + role: "withdrawal", + }, + optionId: "alpha", + scopedProposalId: null, + position: "neutral", + }, saved); +} diff --git a/apps/server/src/conversation-plan/policy-candidate-question.test.ts b/apps/server/src/conversation-plan/policy-candidate-question.test.ts new file mode 100644 index 00000000..7ce9098a --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-question.test.ts @@ -0,0 +1,68 @@ +import { applyEvent } from "./events"; +import { replay } from "./domain"; +import { expect, test } from "bun:test"; +import { ordinaryFrame, ordinaryInput } from "./policy-candidate-ordinary.test-fixtures"; +import { runQuestion } from "./policy-candidate-question"; + +test.each([0.6, 0.599])("question threshold %s preserves proposal vs review", probability => { + let input = ordinaryInput("question"); + input.first.new_question = { type: "noul", noul: probability }; + let { context, entry, role } = ordinaryFrame(input); + expect(role.role).toBe("question"); + let result = runQuestion(context, entry, role); + expect(result).toBeDefined(); + if (probability === 0.6) { + expect(result?.proposed).toMatchObject({ type: "thread.opened", question: input.message.text }); + expect(entry.outcome.targetId).toBe(result?.proposed?.threadId); + } else { + expect(result?.proposed).toBeUndefined(); + expect(entry.outcome.status).toBe("review"); + } + expect(context.events).toHaveLength(0); +}); +test("cached direct-question skips without recomputing from changed first answers", () => { + let { context, entry, role } = ordinaryFrame(ordinaryInput("question")); + context.directQuestion = true; + context.first.new_question = { type: "noul", noul: 0 }; + expect(runQuestion(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("no useful role"); +}); +test.each([{ first: 0.9, again: 0.7 }, { first: 0.899, again: 0.7 }, { first: 0.9, again: 0.699 }])( + "discarded-question bounds $first/$again", + ({ first, again }) => { + let input = ordinaryInput("question"); + input.candidates[0]!.answers.thread = { + type: "choice", + choice: "provider", + confidence: 1, + probabilities: { provider: 1 }, + }; + input.state = applyEvent(input.state, { + id: "discard-provider", + type: "thread.discarded", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Mina" }, + at: 1000, + }); + expect(replay(input.state.events)).toEqual(input.state); + input.first.new_question = { type: "noul", noul: first }; + input.candidates[0]!.answers.raises_again = { type: "noul", noul: again }; + let { context, entry, role } = ordinaryFrame(input); + let result = runQuestion(context, entry, role); + if (first === 0.9 && again === 0.7) expect(result?.proposed?.type).toBe("thread.opened"); + else { + expect(result).toBeUndefined(); + expect(entry.outcome.gate).toBe("discarded question not raised again"); + } + }, +); + +test("a cached direct question progresses when an option group already exists", () => { + let { context, entry, role } = ordinaryFrame(ordinaryInput("question")); + context.directQuestion = true; + context.candidateRun!.optionGroup = "provider"; + expect(runQuestion(context, entry, role)?.proposed?.type).toBe("thread.opened"); + expect(context.events).toHaveLength(0); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-question.ts b/apps/server/src/conversation-plan/policy-candidate-question.ts new file mode 100644 index 00000000..25c1a4d8 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-question.ts @@ -0,0 +1,48 @@ +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import type { Event } from "./policy-types"; +import { noul } from "./policy-scoring"; +import { stableId } from "./policy-identity"; + +/** Invoke only for its matching captured role; undefined skips, a result continues without application. */ +export function runQuestion( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { channelId, message, first, directQuestion } = context; + let { index, candidate, outcome } = entry; + let { thread, target } = frame; + let { optionGroup } = context.candidateRun!; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + let proposed: Event | undefined = undefined; + + if (directQuestion && !optionGroup) return undefined; + let raisesDiscarded = thread?.status === "discarded"; + if ( + raisesDiscarded + && (noul(first, "new_question") < 0.9 + || noul(candidate.answers, "raises_again") < 0.7) + ) { + outcome.gate = "discarded question not raised again"; + return undefined; + } + if ((target === "new" && noul(first, "new_question") >= 0.6) || raisesDiscarded) { + let threadId = `thread:${stableId(channelId, message.id, index, "thread").slice(11)}`; + outcome.targetId = threadId; + proposed = { + ...base(threadId, "thread.opened"), + type: "thread.opened", + source: source("question"), + question: candidate.quote, + }; + } else { + outcome.status = "review"; + outcome.gate = "question target needs review"; + } + + return { proposed }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-reopening.test.ts b/apps/server/src/conversation-plan/policy-candidate-reopening.test.ts new file mode 100644 index 00000000..1d9a1406 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-reopening.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; +import { replay } from "./domain"; +import { reopeningInput, resolutionFrame } from "./policy-candidate-resolution.test-fixtures"; +import { runReopening } from "./policy-candidate-reopening"; + +test.each([{ first: 0.8, follow: 0.8 }, { first: 0.799, follow: 0.8 }, { + first: 0.8, + follow: 0.799, +}])("explicit reopening thresholds $first/$follow", ({ first, follow }) => { + let input = reopeningInput(); + input.first.reopening = { type: "noul", noul: first }; + input.candidates[0]!.answers.reopening = { type: "noul", noul: follow }; + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = resolutionFrame(input); + let result = runReopening(context, entry, role); + expect(result).toBeDefined(); + if (first === 0.8 && follow === 0.8) { + expect(result?.proposed?.type).toBe("candidate.proposed"); + expect(context.reviews).toHaveLength(1); + } else { + expect(result?.proposed).toBeUndefined(); + expect(entry.outcome.gate).toBe("reopening needs review"); + expect(context.reviews).toHaveLength(0); + } + expect(context.events).toHaveLength(0); +}); +test("duplicate reopening review is an explicit handled skip", () => { + let { context, entry, role } = resolutionFrame(reopeningInput()); + context.reviews.push({ id: "existing", kind: "reopening", targetId: "provider" }); + expect(runReopening(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("reopening review already proposed from this message"); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-reopening.ts b/apps/server/src/conversation-plan/policy-candidate-reopening.ts new file mode 100644 index 00000000..4406044f --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-reopening.ts @@ -0,0 +1,47 @@ +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import { stableId } from "./policy-identity"; +import type { Event } from "./policy-types"; +import { directReopening } from "./policy-cues"; +import { noul } from "./policy-scoring"; + +/** Invoke only for matching captured reopening; a result progresses without ordinary application. */ +export function runReopening( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { channelId, message, first, reviews } = context; + let { thread } = frame; + let { index, candidate, outcome } = entry; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + let proposed: Event | undefined = undefined; + + if ( + thread?.status === "decided" && message.author.kind === "member" + && noul(first, "reopening") >= 0.8 + && noul(candidate.answers, "reopening") >= 0.8 && directReopening(candidate.quote) + ) { + if (reviews.some((item) => item.kind === "reopening" && item.targetId === thread.id)) { + outcome.status = "review"; + outcome.gate = "reopening review already proposed from this message"; + return undefined; + } + let id = stableId(channelId, message.id, index, "reopening-candidate"); + proposed = { + ...base(thread.id, "candidate.proposed"), + type: "candidate.proposed", + source: source("reopening"), + candidate: { id, kind: "reopening", text: candidate.quote }, + }; + reviews.push({ id, kind: "reopening", targetId: thread.id }); + } else { + outcome.status = "review"; + outcome.gate = "reopening needs review"; + } + + return { proposed }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-resolution-materialization.test.ts b/apps/server/src/conversation-plan/policy-candidate-resolution-materialization.test.ts new file mode 100644 index 00000000..e2ccc9b4 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-resolution-materialization.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { replay } from "./domain"; +import { resolutionFrame, resolutionInput } from "./policy-candidate-resolution.test-fixtures"; +import { runResolution } from "./policy-candidate-resolution"; + +test("linked card materialization stages versions then publishes the applied state", () => { + let input = resolutionInput(true); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = resolutionFrame(input); + let version = role.thread!.version; + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(context.events.map(event => event.observedThreadVersion)).toEqual([version, version + 1]); + expect(context.events[0]!).toMatchObject({ + type: "option.added", + source: { role: "option" }, + contribution: { id: "beta", text: "Beta", authoring: "scribe", targetId: "provider" }, + }); + expect(context.events[1]!).toMatchObject({ + type: "settle.suggested", + source: { role: "resolution" }, + optionId: "beta", + }); + expect(context.working.threads[0]!.version).toBe(version + 2); + expect(entry.outcome.status).toBe("accepted"); + expect(entry.outcome.eventIds).toEqual(context.events.map(event => event.id)); + expect(replay(context.working.events)).toEqual(context.working); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-resolution.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-resolution.test-fixtures.ts new file mode 100644 index 00000000..896322a7 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-resolution.test-fixtures.ts @@ -0,0 +1,38 @@ +import { + contributionFrame, + contributionInput, + linkContributionCard, +} from "./policy-candidate-contribution.test-fixtures"; +import { decidedInput } from "./policy-candidate-stance.test-fixtures"; +import { confidentChoice } from "./policy-initial.test-fixtures"; +import type { PolicyInput } from "./policy-types"; + +// Offline fixtures for archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/policy.ts consequents1532–1631. +// Original full-policy callbacks remain assigned whole. +export function resolutionInput(materialize = false): PolicyInput { + let option = materialize ? "beta" : "alpha"; + let input = contributionInput("resolution", `Let's use ${materialize ? "Beta" : "Alpha"}.`); + linkContributionCard(input); + let card = input.linkedCards!.get("provider")!; + input.linkedCards = new Map([["provider", { + ...card, + options: [...card.options, { id: "beta", label: "Beta" }], + }]]); + input.first.explicit_resolution = { type: "noul", noul: 0.8 }; + input.candidates[0]!.answers.explicit_resolution = { type: "noul", noul: 0.85 }; + input.candidates[0]!.answers.option = confidentChoice(option); + input.candidates[0]!.answers.chosen_option = confidentChoice(option); + return input; +} +export function reopeningInput() { + let input = contributionInput("reopening", "Let's revisit the provider choice."); + input.state = decidedInput().state; + input.first.reopening = { type: "noul", noul: 0.8 }; + input.candidates[0]!.answers.reopening = { type: "noul", noul: 0.8 }; + return input; +} +export function resolutionFrame(input = resolutionInput()) { + return contributionFrame(input); +} +export { confidentChoice }; diff --git a/apps/server/src/conversation-plan/policy-candidate-resolution.test.ts b/apps/server/src/conversation-plan/policy-candidate-resolution.test.ts new file mode 100644 index 00000000..2de554ae --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-resolution.test.ts @@ -0,0 +1,147 @@ +import { expect, test } from "bun:test"; +import { applyInference, replay } from "./domain"; +import { applyEvent } from "./events"; +import { decidedInput } from "./policy-candidate-stance.test-fixtures"; +import { pendingChoice } from "./policy-candidate-scoped.test-fixtures"; +import { + confidentChoice, + resolutionFrame, + resolutionInput, +} from "./policy-candidate-resolution.test-fixtures"; +import { runResolution } from "./policy-candidate-resolution"; + +test.each([{ marker: 0.8, follow: 0.85 }, { marker: 0.799, follow: 0.85 }, { + marker: 0.8, + follow: 0.849, +}])("credibility markers $marker/$follow", ({ marker, follow }) => { + let input = resolutionInput(); + input.first.explicit_resolution = { type: "noul", noul: marker }; + input.candidates[0]!.answers.explicit_resolution = { type: "noul", noul: follow }; + let { context, entry, role } = resolutionFrame(input); + expect(role.role).toBe("resolution"); + let result = runResolution(context, entry, role); + if (marker === 0.8 && follow === 0.85) { + expect(result?.proposed).toMatchObject({ + type: "settle.suggested", + source: { role: "resolution" }, + optionId: "alpha", + }); + let accepted = applyInference(input.state, result!.proposed!, input.message); + expect(replay(accepted.events)).toEqual(accepted); + } else { + expect(result).toBeUndefined(); + expect(entry.outcome.gate).toBe("settle authority unclear"); + } + expect(context.events).toHaveLength(0); +}); +test("commitment supplies credibility without resolution markers", () => { + let input = resolutionInput(); + input.first.act = confidentChoice("commitment"); + input.first.explicit_resolution = { type: "noul", noul: 0 }; + input.candidates[0]!.answers.explicit_resolution = { type: "noul", noul: 0 }; + let { context, entry, role } = resolutionFrame(input); + expect(runResolution(context, entry, role)?.proposed?.type).toBe("settle.suggested"); +}); +test("captured strong recommendation survives later classifier changes", () => { + let input = resolutionInput(); + input.message.text = "We should use Alpha."; + Object.assign(input.candidates[0]!, { + quote: input.message.text, + end: input.message.text.length, + }); + input.first.explicit_resolution = { type: "noul", noul: 0 }; + input.candidates[0]!.answers.explicit_resolution = { type: "noul", noul: 0 }; + input.candidates[0]!.answers.support = { type: "noul", noul: 0.8 }; + let { context, entry, role } = resolutionFrame(input); + expect(role.strongRecommendation).toBe(true); + context.first.act = confidentChoice("none"); + expect(runResolution(context, entry, role)?.proposed?.type).toBe("settle.suggested"); +}); +test.each(["missing", "decided", "discarded"])("resolution target %s is handled", status => { + let input = resolutionInput(); + if (status === "missing") input.candidates[0]!.answers.thread = confidentChoice("missing"); + if (status === "decided") input.state = decidedInput().state; + if (status === "discarded") { + input.state = applyEvent(input.state, { + id: "discard", + type: "thread.discarded", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Jules" }, + at: 1000, + }); + } + if (status !== "discarded") { + let { context, entry, role } = resolutionFrame(input); + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("settle target needs review"); + } else { + let { context, entry, role } = resolutionFrame(); + // Synthetic frame control: original role capture skips an already discarded target. + context.working = input.state; + role.thread = input.state.threads[0]!; + expect(replay(input.state.events)).toEqual(input.state); + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("settle target needs review"); + } +}); +test.each(["We have decided not to use Alpha.", "Let's use Alpha without backups."])( + "negative chosen source is handled: %s", + quote => { + let input = resolutionInput(); + input.message.text = quote; + Object.assign(input.candidates[0]!, { quote, end: quote.length }); + let { context, entry, role } = resolutionFrame(input); + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(entry.outcome.status).toBe("review"); + expect(entry.outcome.gate).toBe("chosen option needs review"); + }, +); +test.each([{ beta: false, withdrawn: false }, { beta: true, withdrawn: false }, { + beta: false, + withdrawn: true, +}])("current pending choice $beta/$withdrawn", ({ beta, withdrawn }) => { + let input = resolutionInput(beta); + pendingChoice(input, withdrawn); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = resolutionFrame(input); + let result = runResolution(context, entry, role); + if (withdrawn) expect(result?.proposed?.type).toBe("settle.suggested"); + else { + expect(result).toBeUndefined(); + expect(entry.outcome.status).toBe(beta ? "review" : "ignored"); + expect(entry.outcome.gate).toBe( + beta ? "competing choice needs review" : "settle already pending", + ); + } +}); +test("current competing-target set blocks an otherwise valid choice", () => { + let { context, entry, role } = resolutionFrame(); + context.candidateRun!.competingMessageTargets.add("provider"); + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("competing choice needs review"); +}); +test("fresh chosen answer materializes despite the earlier captured chosen ID", () => { + let { context, entry, role } = resolutionFrame(); + expect(role.chosenOption).toBe("alpha"); + entry.candidate.answers.chosen_option = confidentChoice("beta"); + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(context.events.map(event => event.type)).toEqual(["option.added", "settle.suggested"]); + expect(context.working.threads[0]!.pendingSettle?.optionId).toBe("beta"); +}); +test.each(["removed", "restored"])("fresh card lookup overrides captured card: %s", variant => { + let input = resolutionInput(true), card = input.linkedCards!.get("provider")!; + if (variant === "restored") { + input.linkedCards = new Map([["provider", { ...card, cardId: "stale" }]]); + } + let { context, entry, role } = resolutionFrame(input); + let captured = role.linkedCard; + context.input.linkedCards = variant === "removed" ? new Map() : new Map([["provider", card]]); + expect(runResolution(context, entry, role)).toBeUndefined(); + expect(role.linkedCard).toBe(captured); + if (variant === "removed") { + expect(entry.outcome.gate).toBe("chosen option needs review"); + expect(context.events).toHaveLength(0); + } else expect(context.events).toHaveLength(2); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-resolution.ts b/apps/server/src/conversation-plan/policy-candidate-resolution.ts new file mode 100644 index 00000000..70bb8699 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-resolution.ts @@ -0,0 +1,107 @@ +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import type { Event } from "./policy-types"; +import { applyInference } from "./domain"; +import { directResolution } from "./policy-cues"; +import { choice, noul } from "./policy-scoring"; +import { effectivePending } from "./preference"; + +/** Invoke only for matching captured resolution; a result progresses without ordinary application. */ +export function runResolution( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { input, message, first, events } = context; + let { thread, strongRecommendation } = frame; + let { competingMessageTargets } = context.candidateRun!; + let { candidate, outcome } = entry; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + let proposed: Event | undefined = undefined; + try { + let marker = noul(first, "explicit_resolution"); + let follow = noul(candidate.answers, "explicit_resolution"); + let credible = message.author.kind === "member" + && ((directResolution(candidate.quote) + && (marker >= 0.8 && follow >= 0.85 || choice(first, "act") === "commitment")) + || strongRecommendation); + if (!credible) { + outcome.status = "review"; + outcome.gate = "settle authority unclear"; + return undefined; + } + if (!thread || thread.status === "decided" || thread.status === "discarded") { + outcome.status = "review"; + outcome.gate = "settle target needs review"; + return undefined; + } + let chosen = choice(candidate.answers, "chosen_option"); + let card = input.linkedCards?.get(thread.id); + let cardOption = card && card.cardId === thread.questionnaireId + ? card.options.find(item => item.id === chosen) + : undefined; + let chosenOption = chosen + && thread.contributions.some((item) => item.id === chosen && item.kind === "option") + ? chosen + : cardOption?.id; + if (!chosenOption || /\b(?:not|don't|do not|without)\b/i.test(candidate.quote)) { + outcome.status = "review"; + outcome.gate = "chosen option needs review"; + return undefined; + } + let pending = effectivePending(thread, working.events); + if (pending || competingMessageTargets.has(thread.id)) { + let sameOption = pending?.optionId === chosenOption + && !competingMessageTargets.has(thread.id); + outcome.status = sameOption ? "ignored" : "review"; + outcome.gate = sameOption ? "settle already pending" : "competing choice needs review"; + return undefined; + } + if (cardOption && !thread.contributions.some(item => item.id === cardOption.id)) { + let added: Event = { + ...base(thread.id, "option.added"), + type: "option.added", + source: source("option"), + contribution: { + id: cardOption.id, + text: cardOption.label, + authoring: "scribe", + targetId: thread.id, + }, + }; + try { + let staged = applyInference(working, added, message); + let stagedThread = staged.threads.find(item => item.id === thread.id)!; + let suggested: Event = { + ...base(thread.id, "settle.suggested"), + type: "settle.suggested", + observedThreadVersion: stagedThread.version, + source: source("resolution"), + optionId: cardOption.id, + }; + working = applyInference(staged, suggested, message); + events.push(added, suggested); + outcome.eventIds.push(added.id, suggested.id); + outcome.status = "accepted"; + outcome.gate = "accepted"; + } catch { + outcome.status = "review"; + outcome.gate = "linked card choice could not be suggested"; + } + return undefined; + } + proposed = { + ...base(thread.id, "settle.suggested"), + type: "settle.suggested", + source: source("resolution"), + optionId: chosenOption, + }; + + return { proposed }; + } finally { + context.working = working; + } +} diff --git a/apps/server/src/conversation-plan/policy-candidate-role.test.ts b/apps/server/src/conversation-plan/policy-candidate-role.test.ts new file mode 100644 index 00000000..acd75fee --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-role.test.ts @@ -0,0 +1,205 @@ +import { expect, test } from "bun:test"; +import { beginCandidate } from "./policy-candidate-entry"; +import { captureCandidateRole } from "./policy-candidate-role"; +import { candidateContext, roleInput } from "./policy-candidate-entry.test-fixtures"; +import { groupInput } from "./policy-candidate.test-fixtures"; +import { confidentChoice } from "./policy-initial.test-fixtures"; + +function capture(input = roleInput()) { + let context = candidateContext(input); + let entry = beginCandidate(context, 0, input.candidates[0]!)!; + return { context, entry, frame: captureCandidateRole(context, entry) }; +} + +test("captures target, linked card and pending references once for later stages", () => { + let input = roleInput(); + let thread = input.state.threads[0]!; + thread.pendingSettle = { optionId: "alpha", proposer: "Jules", messageId: "proposal" }; + thread.questionnaireId = "card"; + let card = { cardId: "card", options: [{ id: "alpha", label: "Alpha" }] }; + input.linkedCards = new Map([["provider", card]]); + let { context, frame } = capture(input); + expect(frame!.target).toBe("provider"); + expect(frame!.option).toBe("alpha"); + expect(frame!.pending).toBe(thread.pendingSettle); + expect(frame!.targetThread).toBe(thread); + expect(frame!.thread).toBe(thread); + expect(frame!.linkedCard).toBe(card); + context.working = { ...context.working, threads: [] }; + input.linkedCards = new Map(); + expect(frame!.targetThread).toBe(thread); + expect(frame!.pending).toBe(thread.pendingSettle); + expect(frame!.linkedCard).toBe(card); + expect(context.selectedTarget).toBe("provider"); +}); + +test("a split reason/support judgment becomes a reason only with corroborated evidence", () => { + let input = roleInput("reason", "This saves time because it reduces latency."); + input.candidates[0]!.answers.role = { + type: "choice", + choice: "reason", + confidence: 0.9, + probabilities: { reason: 0.45, support: 0.44, none: 0.11 }, + }; + expect(capture(input).frame!.role).toBe("reason"); + input.first.reason = { type: "noul", noul: 0.79 }; + expect(capture(input).frame).toBeUndefined(); +}); + +test("agreement corroboration uses pending proposer and option before role selection", () => { + let input = roleInput("support", "Agreed."); + input.state.threads[0]!.pendingSettle = { + optionId: "alpha", + proposer: "Jules", + messageId: "proposal", + }; + input.candidates[0]!.answers.support = { type: "noul", noul: 0.1 }; + input.candidates[0]!.answers.agrees_with_settle = { type: "noul", noul: 0.7 }; + expect(capture(input).frame!.role).toBe("support"); + input.state.threads[0]!.pendingSettle!.proposer = "Mina"; + expect(capture(input).frame).toBeUndefined(); +}); + +test("qualified pending evidence retains its pending choice and changes the role to constraint", () => { + let input = roleInput("reason", "Verify latency before choosing."); + let thread = input.state.threads[0]!; + thread.pendingSettle = { optionId: "alpha", proposer: "Jules", messageId: "proposal" }; + input.candidates[0]!.answers.relation = confidentChoice("qualifies"); + input.candidates[0]!.answers.qualifies_pending_settle = { type: "noul", noul: 0.8 }; + let { frame } = capture(input); + expect(frame!.role).toBe("constraint"); + expect(frame!.qualifiedPending).toBe(true); + expect(frame!.pending).toBe(thread.pendingSettle); +}); + +test("a strong owned recommendation changes an option role to resolution", () => { + let input = roleInput("option", "We should use Alpha."); + input.candidates[0]!.answers.support = { type: "noul", noul: 0.8 }; + let { frame } = capture(input); + expect(frame!.role).toBe("resolution"); + expect(frame!.strongRecommendation).toBe(true); +}); + +test("an owned known-option commitment can corroborate a below-choice-threshold resolution", () => { + let input = roleInput("resolution", "Let's use Alpha."); + input.first.act = confidentChoice("commitment"); + input.candidates[0]!.answers.role = { + type: "choice", + choice: "resolution", + confidence: 0.95, + probabilities: { resolution: 0.7, none: 0.3 }, + }; + expect(capture(input).frame!.role).toBe("resolution"); +}); + +test("a mixed proposed new choice remains an option before direct commitment evidence", () => { + let input = roleInput("option", "A new provider."); + input.candidates[0]!.answers.chosen_option = confidentChoice("new"); + input.candidates[0]!.answers.role = { + type: "choice", + choice: "resolution", + confidence: 0.9, + probabilities: { resolution: 0.45, option: 0.45, none: 0.1 }, + }; + let { frame } = capture(input); + expect(frame!.role).toBe("option"); + expect(frame!.directNewChoice).toBe(false); +}); + +test("a direct novel recommendation is captured as a resolution without applying its option", () => { + let input = roleInput("option", "We should use Novel."); + input.candidates[0]!.answers.chosen_option = confidentChoice("new"); + input.candidates[0]!.answers.option = confidentChoice("new"); + input.candidates[0]!.answers.support = { type: "noul", noul: 0.8 }; + let { context, frame } = capture(input); + expect(frame!.role).toBe("resolution"); + expect(frame!.directNewChoice).toBe(true); + expect(context.events).toHaveLength(0); +}); + +test.each([0.79, 0.8])( + "withdrawal cue checks owned proposal before role override: %s", + strength => { + let input = roleInput("reason", "I withdraw that choice."); + input.first.withdrawal = { type: "noul", noul: strength }; + let { context, frame } = capture(input); + if (strength < 0.8) { + expect(frame).toBeUndefined(); + expect(context.outcomes[0]!.status).toBe("review"); + expect(context.outcomes[0]!.gate).toBe("pending withdrawal needs a clear owned proposal"); + } else expect(frame!.role).toBe("withdrawal"); + }, +); + +test("group retargeting preserves references captured before the target is overridden", () => { + let context = candidateContext(); + let entry = beginCandidate(context, 0, context.input.candidates[0]!)!; + let frame = captureCandidateRole(context, entry)!; + expect(frame.target).toBe(context.candidateRun!.optionGroup!); + expect(frame.thread).toBe(context.working.threads[0]!); + expect(frame.targetThread).toBeUndefined(); + expect(frame.pending).toBeUndefined(); + expect(frame.linkedCard).toBeUndefined(); +}); + +test("seen labels remain consumed after later role rejection", () => { + let context = candidateContext(); + let entry = beginCandidate(context, 0, context.input.candidates[0]!)!; + entry.candidate.answers.role = { + type: "choice", + choice: "reason", + confidence: 0.1, + probabilities: { reason: 0.4, none: 0.6 }, + }; + entry.candidate.answers.planning_substance = { type: "noul", noul: 0 }; + expect(captureCandidateRole(context, entry)).toBeUndefined(); + expect([...context.candidateRun!.seenOptionLabels]).toEqual(["alpha"]); + expect(context.events).toHaveLength(1); +}); + +test("role none progresses while preserving the prior selected target", () => { + let input = roleInput("none"); + let context = candidateContext(input); + context.selectedTarget = "prior"; + let entry = beginCandidate(context, 0, input.candidates[0]!)!; + expect(captureCandidateRole(context, entry)!.role).toBe("none"); + expect(context.selectedTarget).toBe("prior"); +}); + +test("Planner stance rejection precedes selected-target mutation", () => { + let input = roleInput("support", "Agreed."); + input.message.author = { kind: "agent" }; + let { context, frame } = capture(input); + expect(frame).toBeUndefined(); + expect(context.selectedTarget).toBeUndefined(); + expect(context.outcomes[0]!.gate).toBe("Planner cannot cast a human stance or decision"); +}); + +test("selected target and outcome target survive the later discarded-thread skip", () => { + let input = roleInput(); + input.state.threads[0]!.status = "discarded"; + let { context, frame } = capture(input); + expect(frame).toBeUndefined(); + expect(context.selectedTarget).toBe("provider"); + expect(context.outcomes[0]!.targetId).toBe("provider"); + expect(context.outcomes[0]!.gate).toBe("thread discarded"); +}); + +test("the sequential first question becomes an option only after group retargeting", () => { + let input = groupInput(["Should we host locally?", "Or use a gateway?", "Or use keys?"]); + input.candidates[0]!.answers.role = confidentChoice("question"); + let { context, frame } = capture(input); + expect(context.candidateRun!.sequentialChoiceQuestions).toBe(true); + expect(frame!.role).toBe("option"); + expect(frame!.target).toBe(context.candidateRun!.optionGroup!); + expect(frame!.targetThread).toBeUndefined(); +}); + +test("a question role can progress on a discarded thread", () => { + let input = roleInput("question"); + input.state.threads[0]!.status = "discarded"; + let { context, frame } = capture(input); + expect(frame!.role).toBe("question"); + expect(frame!.thread!.status).toBe("discarded"); + expect(context.selectedTarget).toBe("provider"); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-role.ts b/apps/server/src/conversation-plan/policy-candidate-role.ts new file mode 100644 index 00000000..6ea6f8d3 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-role.ts @@ -0,0 +1,191 @@ +import type { CandidateEntry } from "./policy-candidate-entry"; +import type { PolicyContext } from "./policy-context"; +import { directOwnedCommitment, directRecommendation } from "./policy-cues"; +import { choice, noul, roleChoice } from "./policy-scoring"; +import { effectivePending } from "./preference"; +import { hasWithdrawalCue } from "./question-shared"; + +/** Undefined skips this candidate; returned references keep their original pre-retarget timing. */ +export function captureCandidateRole(context: PolicyContext, entry: CandidateEntry) { + let { input, message, first } = context; + let { index, candidate, outcome } = entry; + let { optionGroup, sequentialChoiceQuestions } = context.candidateRun!; + let working = context.working; + let selectedTarget = context.selectedTarget; + try { + let target = choice(candidate.answers, "thread"); + let option = choice(candidate.answers, "option"); + let pendingThread = working.threads.find(item => item.id === target); + let pending = pendingThread && effectivePending(pendingThread, working.events); + let pendingAgreement = pending && message.author.kind === "member" + && message.author.handle !== pending.proposer + && (!option || option === "none" || option === pending.optionId) + && noul(candidate.answers, "agrees_with_settle") >= 0.7; + let role = roleChoice(candidate.answers, first, Boolean(pendingAgreement)); + let roleAnswer = candidate.answers.role; + let targetThread = working.threads.find((item) => item.id === target); + let chosenOption = choice(candidate.answers, "chosen_option"); + let linkedCard = targetThread && input.linkedCards?.get(targetThread.id); + let knownChosenOption = !!targetThread && !!chosenOption + && (targetThread.contributions.some(item => + item.kind === "option" && item.id === chosenOption + ) || !!linkedCard && linkedCard.cardId === targetThread.questionnaireId + && linkedCard.options.some(item => item.id === chosenOption)); + let reasonProbability = roleAnswer?.type === "choice" + ? roleAnswer.probabilities.reason ?? 0 + : 0; + let supportProbability = roleAnswer?.type === "choice" + ? roleAnswer.probabilities.support ?? 0 + : 0; + if ( + !role && roleAnswer?.type === "choice" + && ["reason", "support"].includes(roleAnswer.choice) + && reasonProbability >= 0.35 && supportProbability >= 0.3 + && reasonProbability + supportProbability >= 0.8 + && Math.abs(reasonProbability - supportProbability) <= 0.12 + && targetThread && !["decided", "discarded"].includes(targetThread.status) + && choice(first, "thread_target") === targetThread.id + && option && targetThread.contributions.some(item => + item.kind === "option" && item.id === option + ) + && ["supports", "challenges", "qualifies"].includes( + choice(candidate.answers, "relation") ?? "", + ) + && noul(first, "reason") >= 0.8 + && first.significance?.type === "score" && first.significance.score >= 1 + && noul(candidate.answers, "planning_substance") >= 0.8 + && noul(candidate.answers, "duplicate") < 0.3 + && /\b(?:because|since|reduces?|avoids?|saves?|improves?|allows?|enables?|so that|so we can|lets? us)\b/i + .test(candidate.quote) + ) role = "reason"; + let relationAnswer = candidate.answers.relation; + let qualifiedPending = pending && targetThread + && targetThread.status !== "decided" && targetThread.status !== "discarded" + && targetThread.contributions.some(item => + item.kind === "option" && item.id === pending.optionId + ) + && relationAnswer?.type === "choice" && relationAnswer.choice === "qualifies" + && (relationAnswer.probabilities.qualifies ?? 0) >= 0.7 + && (relationAnswer.probabilities.qualifies ?? 0) + - Math.max( + 0, + ...Object.entries(relationAnswer.probabilities) + .filter(([key]) => key !== "qualifies").map(([, value]) => value), + ) >= 0.2 + && noul(candidate.answers, "qualifies_pending_settle") >= 0.8 + && noul(candidate.answers, "planning_substance") >= 0.8 + && noul(candidate.answers, "duplicate") < 0.3 + && first.significance?.type === "score" && first.significance.score >= 1; + if ( + qualifiedPending && (!role || ["option", "reason", "constraint", "support"].includes(role)) + ) { + role = "constraint"; + } + let strongRecommendation = message.author.kind === "member" + && directRecommendation(candidate.quote) + && choice(first, "act") === "proposal" + && noul(candidate.answers, "support") >= 0.8 + && !!targetThread && !["decided", "discarded"].includes(targetThread.status) + && (!option || option === "none" || option === chosenOption) + && knownChosenOption; + let resolutionProbability = roleAnswer?.type === "choice" + ? roleAnswer.probabilities.resolution ?? 0 + : 0; + let optionProbability = roleAnswer?.type === "choice" + ? roleAnswer.probabilities.option ?? 0 + : 0; + if ( + !role && message.author.kind === "member" && targetThread + && !["decided", "discarded"].includes(targetThread.status) + && directOwnedCommitment(candidate.quote) + && choice(first, "act") === "commitment" + && resolutionProbability >= 0.7 + && knownChosenOption + ) role = "resolution"; + // A proposed new choice can sound like a resolution; show it without settling it. + if ( + !role && targetThread && !["decided", "discarded"].includes(targetThread.status) + && choice(candidate.answers, "chosen_option") === "new" + && resolutionProbability >= 0.3 && optionProbability >= 0.3 + && resolutionProbability + optionProbability >= 0.85 + && Math.abs(resolutionProbability - optionProbability) <= 0.15 + && noul(first, "new_option") >= 0.8 + && noul(candidate.answers, "new_option") >= 0.85 + && noul(candidate.answers, "planning_substance") >= 0.7 + && noul(candidate.answers, "duplicate") < 0.8 + && first.significance?.type === "score" && first.significance.score >= 2 + ) role = "option"; + if (strongRecommendation && (!role || role === "option" || role === "support")) { + role = "resolution"; + } + let directNewChoice = message.author.kind === "member" && !!targetThread + && !["decided", "discarded"].includes(targetThread.status) + && chosenOption === "new" && option === "new" + && (!role || ["option", "support", "resolution"].includes(role)) + && candidate.quote.length <= 500 && !candidate.quote.trim().endsWith("?") + && !/\b(?:not|don't|do not|without|if|unless)\b/i.test(candidate.quote) + && noul(first, "new_option") >= 0.8 + && noul(candidate.answers, "new_option") >= 0.85 + && noul(candidate.answers, "planning_substance") >= 0.7 + && noul(candidate.answers, "duplicate") < 0.3 + && !targetThread.contributions.some(item => + item.kind === "option" + && item.text.trim().replace(/\s+/g, " ").toLowerCase() + === candidate.quote.trim().replace(/\s+/g, " ").toLowerCase() + ) + && (directRecommendation(candidate.quote) + && choice(first, "act") === "proposal" + && noul(candidate.answers, "support") >= 0.8 + || directOwnedCommitment(candidate.quote) + && choice(first, "act") === "commitment" + && noul(first, "explicit_resolution") >= 0.8 + && noul(candidate.answers, "explicit_resolution") >= 0.8); + if (directNewChoice) role = "resolution"; + if (hasWithdrawalCue(candidate.quote)) { + if (noul(first, "withdrawal") < 0.8) { + outcome.status = "review"; + outcome.gate = "pending withdrawal needs a clear owned proposal"; + return undefined; + } + role = "withdrawal"; + } + if (optionGroup) target = optionGroup; + if (optionGroup && sequentialChoiceQuestions && index === 0 && role === "question") { + role = "option"; + } + if (!role) return undefined; + if ( + message.author.kind === "agent" + && ["support", "objection", "withdrawal", "resolution", "reopening"].includes(role) + ) { + outcome.gate = "Planner cannot cast a human stance or decision"; + return undefined; + } + if (role !== "none" && target && !["new", "none"].includes(target)) { + selectedTarget = target; + outcome.targetId = target; + } + let thread = working.threads.find((item) => item.id === target); + if (thread?.status === "discarded" && role !== "question") { + outcome.gate = "thread discarded"; + return undefined; + } + return { + target, + option, + pending, + role, + targetThread, + chosenOption, + linkedCard, + qualifiedPending, + strongRecommendation, + directNewChoice, + thread, + }; + } finally { + context.selectedTarget = selectedTarget; + } +} + +export type CandidateRole = NonNullable>; diff --git a/apps/server/src/conversation-plan/policy-candidate-scoped.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-scoped.test-fixtures.ts new file mode 100644 index 00000000..b0632ed4 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-scoped.test-fixtures.ts @@ -0,0 +1,119 @@ +import { expect } from "bun:test"; +import { applyInference } from "./domain"; +import { applyEvent } from "./events"; +import { beginCandidate } from "./policy-candidate-entry"; +import { candidateContext, roleInput } from "./policy-candidate-entry.test-fixtures"; +import { captureCandidateRole } from "./policy-candidate-role"; +import { runCandidateVerification } from "./policy-candidate-verification"; +import { confidentChoice, message } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import type { Event, PolicyInput } from "./policy-types"; + +// Pure handler fixtures for archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// policy.ts statements1160–1295. Full downstream callbacks remain assigned. +export let cardId = "01M3QAQ8HM8DVYA9E4N28QCQ7T"; +export function scopedInput(assent = false): PolicyInput { + let input = roleInput( + "support", + assent ? "yep, Alpha for a spike." : "I'd pick Alpha for a spike;", + ); + input.state = stateWithOption("Which provider?", "provider", "alpha", "Alpha"); + input.state = applyEvent(input.state, { + id: "card-link", + type: "card.linked", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + questionnaireId: cardId, + }); + input.linkedCards = new Map([["provider", { + cardId, + options: [{ id: "alpha", label: "Alpha" }, { id: "beta", label: "Beta" }], + }]]); + input.first.support = { type: "noul", noul: 0.95 }; + if (assent) { + let saved = message("scoped-proposal-message", "I'd pick Alpha for a spike;", "Jules"); + input.state = applyInference(input.state, { + id: "scoped-proposal", + type: "scoped-choice.proposed", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: saved.id, + author: { kind: "member", handle: "Jules" }, + quote: saved.text, + start: 0, + end: saved.text.length, + role: "support", + }, + cardId, + optionId: "alpha", + label: "Alpha", + scope: "spike", + }, saved); + } + return input; +} + +export function newChoiceInput(): PolicyInput { + let input = roleInput("option", "We should use Novel."); + input.state = stateWithOption("Which provider?", "provider", "alpha", "Alpha"); + input.candidates[0]!.answers.option = confidentChoice("new"); + input.candidates[0]!.answers.chosen_option = confidentChoice("new"); + input.candidates[0]!.answers.support = { type: "noul", noul: 0.95 }; + return input; +} + +export function pendingChoice(input: PolicyInput, withdrawn = false): void { + let saved = message("settle-message", "Let's use Alpha.", "Jules"); + let base = (id: string) => ({ + id, + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier" as const, + actor: { kind: "classifier" as const }, + at: 1000, + }); + let citation = ( + role: "resolution" | "withdrawal", + ): Extract["source"] => ({ + messageId: saved.id, + author: { kind: "member", handle: "Jules" }, + quote: saved.text, + start: 0, + end: saved.text.length, + role, + }); + input.state = applyInference(input.state, { + ...base("settle"), + type: "settle.suggested", + source: citation("resolution"), + optionId: "alpha", + }, saved); + if (withdrawn) { + saved = message("withdraw-message", "I withdraw that choice.", "Jules"); + input.state = applyInference(input.state, { + ...base("withdraw"), + type: "stance.changed", + source: citation("withdrawal"), + optionId: "alpha", + scopedProposalId: null, + position: "neutral", + }, saved); + } +} + +export function scopedFrame(input = scopedInput()) { + let context = candidateContext(input); + let entry = beginCandidate(context, 0, input.candidates[0]!)!; + let role = captureCandidateRole(context, entry)!; + let spike = runCandidateVerification(context, entry, role); + expect(spike).toBeDefined(); + if (!spike) throw new Error("fixture handled before scoped handlers"); + return { context, entry, role, spike }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-scoped.test.ts b/apps/server/src/conversation-plan/policy-candidate-scoped.test.ts new file mode 100644 index 00000000..90ca0797 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-scoped.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from "bun:test"; +import { replay } from "./domain"; +import { runScopedAssent, runScopedProposal } from "./policy-candidate-scoped"; +import { cardId, scopedFrame, scopedInput } from "./policy-candidate-scoped.test-fixtures"; + +test("a scoped proposal uses the linked card and cached spike label without choosing globally", () => { + let input = scopedInput(); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role, spike } = scopedFrame(input); + let before = structuredClone(input); + expect(runScopedAssent(context, entry, role)).toBe(true); + expect(runScopedProposal(context, entry, role, spike)).toBeUndefined(); + expect(context.events[0]!).toMatchObject({ + type: "scoped-choice.proposed", + cardId, + optionId: "alpha", + label: "Alpha", + scope: "spike", + }); + expect(context.working.threads[0]!.pendingScopedChoice!.proposer).toBe("Mina"); + expect(context.working.threads[0]!.pendingSettle).toBeUndefined(); + expect(entry.outcome.status).toBe("accepted"); + expect(input).toEqual(before); + expect(replay(context.working.events)).toEqual(context.working); +}); + +test("another member independently agrees with the exact scoped proposal", () => { + let input = scopedInput(true); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = scopedFrame(input); + expect(runScopedAssent(context, entry, role)).toBeUndefined(); + expect(context.events[0]!).toMatchObject({ + type: "scoped-choice.agreed", + proposalId: "scoped-proposal", + cardId, + optionId: "alpha", + label: "Alpha", + scope: "spike", + }); + expect(entry.outcome.eventIds).toEqual([context.events[0]!.id]); + expect(context.working.threads[0]!.pendingSettle).toBeUndefined(); + expect(replay(context.working.events)).toEqual(context.working); +}); + +test.each(["proposer", "wrong card", "duplicate label", "weak option", "weak chosen"])( + "assent %s falls through", + kind => { + let input = scopedInput(true); + if (kind === "proposer") input.message.author = { kind: "member", handle: "Jules" }; + if (kind === "wrong card") input.linkedCards!.get("provider")!.cardId = "other-card"; + if (kind === "duplicate label") { + input.linkedCards = new Map([["provider", { + cardId, + options: [{ id: "alpha", label: "Alpha" }, { id: "alias", label: "Alpha" }], + }]]); + } + if (kind === "weak option") { + input.candidates[0]!.answers.option = { + type: "choice", + choice: "alpha", + confidence: 0.95, + probabilities: { alpha: 0.49, none: 0.51 }, + }; + } + if (kind === "weak chosen") { + input.candidates[0]!.answers.chosen_option = { + type: "choice", + choice: "alpha", + confidence: 0.95, + probabilities: { alpha: 0.59, none: 0.41 }, + }; + } + let { context, entry, role } = scopedFrame(input); + expect(runScopedAssent(context, entry, role)).toBe(true); + expect(context.events).toHaveLength(0); + }, +); + +test("assent accepts its raw option/chosen probabilities at their original bounds", () => { + let input = scopedInput(true); + input.candidates[0]!.answers.option = { + type: "choice", + choice: "alpha", + confidence: 0.95, + probabilities: { alpha: 0.5, none: 0.5 }, + }; + input.candidates[0]!.answers.chosen_option = { + type: "choice", + choice: "alpha", + confidence: 0.95, + probabilities: { alpha: 0.6, none: 0.4 }, + }; + let { context, entry, role } = scopedFrame(input); + expect(role.option).toBeUndefined(); + expect(role.chosenOption).toBeUndefined(); + expect(runScopedAssent(context, entry, role)).toBeUndefined(); +}); + +test("missing proposal lineage falls through even with a captured pending choice", () => { + let { context, entry, role } = scopedFrame(scopedInput(true)); + context.working = { + ...context.working, + events: context.working.events.filter(event => event.id !== "scoped-proposal"), + }; + expect(runScopedAssent(context, entry, role)).toBe(true); + expect(context.events).toHaveLength(0); +}); + +test("the duplicate proposal check retains the captured pending option and ignored status", () => { + let input = scopedInput(true); + input.message.text = "I'd pick Alpha for a spike;"; + input.candidates[0]!.quote = input.message.text; + input.candidates[0]!.end = input.message.text.length; + let { context, entry, role, spike } = scopedFrame(input); + expect(runScopedProposal(context, entry, role, spike)).toBeUndefined(); + expect(entry.outcome.gate).toBe("spike choice already pending"); + expect(entry.outcome.status).toBe("ignored"); + expect(context.events).toHaveLength(0); +}); + +test("proposal uses the cached label rather than reparsing the valid quote", () => { + let { context, entry, role, spike } = scopedFrame(); + spike.spikeLabel = "Beta"; + expect(runScopedProposal(context, entry, role, spike)).toBe(true); + expect(context.events).toHaveLength(0); +}); + +test("a replacement input card map does not replace the earlier captured linked card", () => { + let { context, entry, role, spike } = scopedFrame(); + let captured = role.linkedCard; + context.input.linkedCards = new Map([["provider", { cardId: "replacement", options: [] }]]); + expect(runScopedProposal(context, entry, role, spike)).toBeUndefined(); + expect(role.linkedCard).toBe(captured); + expect(context.events[0]!).toMatchObject({ type: "scoped-choice.proposed", cardId }); +}); + +test.each(["absent", "ambiguous"])("proposal requires one named linked-card label: %s", kind => { + let input = scopedInput(); + input.linkedCards = new Map([["provider", { + cardId, + options: kind === "absent" + ? [{ id: "beta", label: "Beta" }] + : [{ id: "alpha", label: "Alpha" }, { id: "alias", label: "Alpha" }], + }]]); + let { context, entry, role, spike } = scopedFrame(input); + expect(runScopedProposal(context, entry, role, spike)).toBe(true); + expect(context.events).toHaveLength(0); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-scoped.ts b/apps/server/src/conversation-plan/policy-candidate-scoped.ts new file mode 100644 index 00000000..6b0e9608 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-scoped.ts @@ -0,0 +1,141 @@ +import { applyInference } from "./domain"; +import { currentScopedProposal } from "./events"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { runCandidateVerification } from "./policy-candidate-verification"; +import type { PolicyContext } from "./policy-context"; +import { immediateSpikeCondition, namesCardOption } from "./policy-cues"; +import { choice, moderateChoice, noul } from "./policy-scoring"; +import type { Event } from "./policy-types"; +import { spikeAgreementLabel } from "./validation-fields"; + +type Spike = NonNullable>; + +/** Run after verification progresses. Undefined handles/skips; true advances to scoped proposal. */ +export function runScopedAssent( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): true | undefined { + let { message, first, events } = context; + let { candidate, outcome } = entry; + let { targetThread, linkedCard, role } = frame; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + try { + let pendingScoped = targetThread?.pendingScopedChoice; + let assentLabel = spikeAgreementLabel(candidate.quote); + let optionAnswer = candidate.answers.option; + let chosenAnswer = candidate.answers.chosen_option; + if ( + role === "support" && assentLabel && candidate.start === 0 + && !immediateSpikeCondition(message.text, candidate.end) + && message.author.kind === "member" && targetThread && pendingScoped + && !["decided", "discarded"].includes(targetThread.status) + && message.author.handle !== pendingScoped.proposer + && choice(first, "thread_target") === targetThread.id + && noul(first, "support") >= 0.8 && noul(candidate.answers, "support") >= 0.8 + && optionAnswer?.type === "choice" && optionAnswer.choice === pendingScoped.optionId + && (optionAnswer.probabilities[pendingScoped.optionId] ?? 0) >= 0.5 + && chosenAnswer?.type === "choice" && chosenAnswer.choice === pendingScoped.optionId + && (chosenAnswer.probabilities[pendingScoped.optionId] ?? 0) >= 0.6 + && pendingScoped.scope === "spike" + && assentLabel.toLocaleLowerCase() === pendingScoped.label.toLocaleLowerCase() + && linkedCard && linkedCard.cardId === targetThread.questionnaireId + && linkedCard.cardId === pendingScoped.cardId + && linkedCard.options.filter(item => + item.id === pendingScoped.optionId && item.label === pendingScoped.label + ).length === 1 + && linkedCard.options.filter(item => namesCardOption(candidate.quote, item.label)).length + === 1 + ) { + let proposal = currentScopedProposal(targetThread, working.events); + if (proposal) { + let agreed: Event = { + ...base(targetThread.id, "scoped-choice.agreed"), + type: "scoped-choice.agreed", + source: source("support"), + proposalId: proposal.id, + cardId: pendingScoped.cardId, + optionId: pendingScoped.optionId, + label: pendingScoped.label, + scope: "spike", + }; + try { + working = applyInference(working, agreed, message); + events.push(agreed); + outcome.eventIds.push(agreed.id); + outcome.status = "accepted"; + outcome.gate = "accepted"; + } catch { + outcome.status = "review"; + outcome.gate = "scoped agreement needs review"; + } + return undefined; + } + } + return true; + } finally { + context.working = working; + } +} + +/** Requires assent to progress; the spike label is the earlier verification capture. */ +export function runScopedProposal( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, + spike: Spike, +): true | undefined { + let { message, first, events } = context; + let { candidate, outcome } = entry; + let { targetThread, linkedCard, role, option, chosenOption } = frame; + let { spikeLabel } = spike; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + try { + if ( + (role === "support" || role === "option") && spikeLabel + && message.author.kind === "member" && targetThread + && !["decided", "discarded"].includes(targetThread.status) + && moderateChoice(first, "act", "proposal", 0.7) + && choice(first, "thread_target") === targetThread.id + && option && option === chosenOption && !["new", "none"].includes(option) + && noul(candidate.answers, "support") >= 0.8 + && linkedCard && linkedCard.cardId === targetThread.questionnaireId + ) { + let named = linkedCard.options.filter(item => namesCardOption(candidate.quote, item.label)); + let selected = named.length === 1 && named[0]?.id === option ? named[0] : undefined; + if (selected && selected.label.toLocaleLowerCase() === spikeLabel.toLocaleLowerCase()) { + if (targetThread.pendingScopedChoice?.optionId === option) { + outcome.gate = "spike choice already pending"; + return undefined; + } + let scoped: Event = { + ...base(targetThread.id, "scoped-choice.proposed"), + type: "scoped-choice.proposed", + source: source("support"), + cardId: linkedCard.cardId, + optionId: selected.id, + label: selected.label, + scope: "spike", + }; + try { + working = applyInference(working, scoped, message); + events.push(scoped); + outcome.eventIds.push(scoped.id); + outcome.status = "accepted"; + outcome.gate = "accepted"; + } catch { + outcome.status = "review"; + outcome.gate = "scoped choice needs review"; + } + return undefined; + } + } + return true; + } finally { + context.working = working; + } +} diff --git a/apps/server/src/conversation-plan/policy-candidate-setup.test.ts b/apps/server/src/conversation-plan/policy-candidate-setup.test.ts new file mode 100644 index 00000000..e627eaa9 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-setup.test.ts @@ -0,0 +1,240 @@ +import { expect, test } from "bun:test"; +import { prepareCandidateRun } from "./policy-candidate-setup"; +import { choiceInput, groupInput, prepareCandidateContext } from "./policy-candidate.test-fixtures"; +import { confidentChoice } from "./policy-initial.test-fixtures"; +import { stableId } from "./policy-identity"; + +function setup(input = groupInput()) { + return prepareCandidateRun(prepareCandidateContext(input)); +} + +test("groups bounded exact option spans with the original deterministic identity", () => { + let input = groupInput(); + let before = structuredClone(input); + let context = prepareCandidateContext(input); + let run = prepareCandidateRun(context); + expect(run.optionGroup).toBe( + `thread:${stableId("channel", input.message.id, 0, "thread").slice(11)}`, + ); + expect(run.sequentialChoiceQuestions).toBe(false); + expect([...run.seenOptionLabels]).toEqual([]); + expect([...run.competingMessageTargets]).toEqual([]); + expect(context.candidateRun).toBe(run); + expect(input).toEqual(before); +}); + +test("retains sequential question classification and its first question-role exception", () => { + let input = groupInput(["Should we host locally?", "Or use a gateway?", "Or use keys?"]); + input.candidates[0]!.answers.role = confidentChoice("question"); + let run = setup(input); + expect(run.sequentialChoiceQuestions).toBe(true); + expect(run.optionGroup).toBeDefined(); +}); + +test.each([ + "ownership", + "span", + "new option", + "role", + "comma alternatives", + "thread choice", + "duplicate labels", +])("does not create an option group for invalid %s", kind => { + let input = groupInput(); + let candidate = input.candidates[1]!; + if (kind === "ownership") input.first.c1_owned_unretracted = { type: "noul", noul: 0.69 }; + if (kind === "span") candidate.start += 1; + if (kind === "new option") candidate.answers.new_option = { type: "noul", noul: 0.74 }; + if (kind === "role") candidate.answers.role = confidentChoice("reason"); + if (kind === "comma alternatives") input = groupInput(["Alpha, Beta or Gamma", "Delta"]); + if (kind === "thread choice") candidate.answers.thread = confidentChoice("provider"); + if (kind === "duplicate labels") input = groupInput(["Alpha", "alpha"]); + expect(setup(input).optionGroup).toBeUndefined(); +}); + +test("uses raw new/none thread choice for grouping despite low choice probabilities", () => { + let input = groupInput(); + input.candidates[0]!.answers.thread = { + type: "choice", + choice: "new", + confidence: 0.1, + probabilities: { new: 0.1, none: 0.9 }, + }; + expect(setup(input).optionGroup).toBeDefined(); +}); + +test.each([ + { ids: ["alpha", "alpha"], expected: [] }, + { ids: ["alpha", "beta"], expected: ["provider"] }, +])("counts distinct known option IDs: $ids", ({ ids, expected }) => { + expect([...setup(choiceInput(ids)).competingMessageTargets]).toEqual([...expected]); +}); + +test("accepts linked choices only from the thread's matching card", () => { + let input = choiceInput(["linked-a", "linked-b"]); + input.state.threads[0]!.questionnaireId = "card"; + input.linkedCards = new Map([["provider", { + cardId: "card", + options: [{ id: "linked-a", label: "A" }, { id: "linked-b", label: "B" }], + }]]); + expect([...setup(input).competingMessageTargets]).toEqual(["provider"]); + input.linkedCards.get("provider")!.cardId = "wrong-card"; + expect([...setup(input).competingMessageTargets]).toEqual([]); +}); + +test.each([ + { quotes: ["We should use Alpha.", "We should use alpha."], expected: [] }, + { quotes: ["We should use Alpha.", "We should use Beta."], expected: ["provider"] }, +])("normalizes novel recommendations for competition: $quotes", ({ quotes, expected }) => { + let input = choiceInput(["new", "new"]); + let spans = groupInput(quotes); + input.message = spans.message; + input.candidates.forEach((candidate, index) => + Object.assign(candidate, { + start: spans.candidates[index]!.start, + end: spans.candidates[index]!.end, + quote: quotes[index], + }) + ); + expect([...setup(input).competingMessageTargets]).toEqual([...expected]); +}); + +test.each(["agent", "decided", "discarded", "unowned", "wrong quote", "weak support"])( + "excludes competing choices for %s", + kind => { + let input = choiceInput(); + if (kind === "agent") input.message.author = { kind: "agent" }; + if (kind === "decided" || kind === "discarded") input.state.threads[0]!.status = kind; + if (kind === "unowned") input.first.c1_owned_unretracted = { type: "noul", noul: 0.69 }; + if (kind === "wrong quote") input.candidates[1]!.start += 1; + if (kind === "weak support") { + input.candidates[1]!.answers.support = { type: "noul", noul: 0.79 }; + } + expect([...setup(input).competingMessageTargets]).toEqual([]); + }, +); + +test("uses original input state and captured message rather than changed working/input", () => { + let input = choiceInput(); + let context = prepareCandidateContext(input); + context.working = { ...input.state, threads: [] }; + input.message = { ...input.message, text: "changed", author: { kind: "agent" } }; + expect([...prepareCandidateRun(context).competingMessageTargets]).toEqual(["provider"]); +}); + +test("uses cached option labels despite otherwise valid distinct candidate spans", () => { + let context = prepareCandidateContext(groupInput()); + context.facts!.optionLabels = ["alpha", "alpha", "alpha"]; + expect(prepareCandidateRun(context).optionGroup).toBeUndefined(); +}); + +test("owns fresh mutable sets and group state for each candidate run", () => { + let a = setup(); + let b = setup(); + expect(a.seenOptionLabels).not.toBe(b.seenOptionLabels); + expect(a.competingMessageTargets).not.toBe(b.competingMessageTargets); + a.seenOptionLabels.add("alpha"); + a.competingMessageTargets.add("provider"); + a.optionGroup = undefined; + expect([...b.seenOptionLabels]).toEqual([]); + expect([...b.competingMessageTargets]).toEqual([]); + expect(b.optionGroup).toBeDefined(); +}); + +test("preserves competing-thread insertion order across four candidates", () => { + let input = choiceInput(["alpha", "beta", "alpha", "beta"]); + let provider = input.state.threads[0]!; + let other = input.state.threads[1]!; + other.contributions.push({ ...provider.contributions[0]! }); + input.candidates[0]!.answers.thread = confidentChoice("other"); + input.candidates[1]!.answers.thread = confidentChoice("other"); + expect([...setup(input).competingMessageTargets]).toEqual(["other", "provider"]); +}); + +test.each([ + { + novel: false, + linked: false, + act: "commitment", + resolution: 0, + candidateResolution: 0, + expected: ["provider"], + }, + { + novel: false, + linked: true, + act: "commitment", + resolution: 0, + candidateResolution: 0, + expected: ["provider"], + }, + { + novel: false, + linked: false, + act: "proposal", + resolution: 0.8, + candidateResolution: 0.84, + expected: [], + }, + { + novel: false, + linked: false, + act: "proposal", + resolution: 0.8, + candidateResolution: 0.85, + expected: ["provider"], + }, + { + novel: true, + linked: false, + act: "commitment", + resolution: 0.79, + candidateResolution: 0.8, + expected: [], + }, + { + novel: true, + linked: false, + act: "commitment", + resolution: 0.8, + candidateResolution: 0.8, + expected: ["provider"], + }, + { + novel: true, + linked: false, + act: "proposal", + resolution: 1, + candidateResolution: 1, + expected: [], + }, +])( + "uses known/linked vs novel commitment evidence: $novel/$linked/$act/$resolution/$candidateResolution", + ({ novel, linked, act, resolution, candidateResolution, expected }) => { + let input = choiceInput( + novel ? ["new", "new"] : linked ? ["linked-a", "linked-b"] : ["alpha", "beta"], + ); + if (linked) { + input.state.threads[0]!.questionnaireId = "card"; + input.linkedCards = new Map([["provider", { + cardId: "card", + options: [{ id: "linked-a", label: "A" }, { id: "linked-b", label: "B" }], + }]]); + } + let spans = groupInput(["Let's use Alpha.", "Let's use Beta."]); + input.message = spans.message; + input.first.act = confidentChoice(act); + input.first.explicit_resolution = { type: "noul", noul: resolution }; + input.candidates.forEach((candidate, index) => + Object.assign(candidate, { + start: spans.candidates[index]!.start, + end: spans.candidates[index]!.end, + quote: spans.candidates[index]!.quote, + }) + ); + for (let candidate of input.candidates) { + candidate.answers.explicit_resolution = { type: "noul", noul: candidateResolution }; + } + expect([...setup(input).competingMessageTargets]).toEqual([...expected]); + }, +); diff --git a/apps/server/src/conversation-plan/policy-candidate-setup.ts b/apps/server/src/conversation-plan/policy-candidate-setup.ts new file mode 100644 index 00000000..8d50a2b7 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-setup.ts @@ -0,0 +1,96 @@ +import type { CandidateRun, PolicyContext } from "./policy-context"; +import { + COMMA_ALTERNATIVES, + directOwnedCommitment, + directRecommendation, + OWNED_QUOTE_MIN, +} from "./policy-cues"; +import { stableId } from "./policy-identity"; +import { choice, noul, roleChoice } from "./policy-scoring"; +import { MAX_QUOTE_CANDIDATES } from "./quote-budget"; + +/** Prepare once after both terminal runners continue; later candidates share this mutable run. */ +export function prepareCandidateRun(context: PolicyContext): CandidateRun { + let { input, channelId, message, first } = context; + let { optionLabels } = context.facts!; + let seenOptionLabels = new Set(); + let sequentialChoiceQuestions = input.candidates.length === 3 + && input.candidates[0]?.start === 0 + && input.candidates[2]?.end === message.text.length + && /^Should we\b[^?]{1,160}\?$/i.test(input.candidates[0]?.quote ?? "") + && input.candidates.slice(1).every((candidate, index) => + /^Or\b[^?]{1,160}\?$/i.test(candidate.quote) + && /^\s+$/.test(message.text.slice(input.candidates[index]!.end, candidate.start)) + ); + let optionGroup = input.state.threads.length === 0 && message.text.length <= 500 + && noul(first, "new_question") >= 0.9 + && choice(first, "act") === "question" && choice(first, "thread_target") === "new" + && input.candidates.length >= 2 && input.candidates.length <= MAX_QUOTE_CANDIDATES + && new Set(optionLabels).size >= 2 + && input.candidates.every((candidate, index) => + noul(first, `c${index}_owned_unretracted`) >= OWNED_QUOTE_MIN + && Number.isSafeInteger(candidate.start) && candidate.start >= 0 + && Number.isSafeInteger(candidate.end) && candidate.end <= message.text.length + && candidate.end > candidate.start + && candidate.quote.length <= 500 && !!candidate.quote.trim() + && message.text.slice(candidate.start, candidate.end) === candidate.quote + && (roleChoice(candidate.answers, first) === "option" + || sequentialChoiceQuestions && index === 0 + && roleChoice(candidate.answers, first) === "question") + && !COMMA_ALTERNATIVES.test(candidate.quote.trim()) + && noul(candidate.answers, "new_option") >= 0.75 + && candidate.answers.thread?.type === "choice" + && ["new", "none"].includes(candidate.answers.thread.choice) + ) + ? `thread:${stableId(channelId, message.id, 0, "thread").slice(11)}` + : undefined; + let choicesByThread = new Map>(); + for (let [index, candidate] of input.candidates.entries()) { + let target = choice(candidate.answers, "thread"); + let chosen = choice(candidate.answers, "chosen_option"); + let namedOption = choice(candidate.answers, "option"); + let thread = input.state.threads.find(item => item.id === target); + if ( + message.author.kind !== "member" || !thread + || ["decided", "discarded"].includes(thread.status) + || noul(first, `c${index}_owned_unretracted`) < OWNED_QUOTE_MIN + || message.text.slice(candidate.start, candidate.end) !== candidate.quote + ) continue; + let known = chosen + && thread.contributions.some(item => item.kind === "option" && item.id === chosen); + let card = input.linkedCards?.get(thread.id); + let linkedOption = card && card.cardId === thread.questionnaireId + && card.options.some(item => item.id === chosen); + let novel = chosen === "new" && namedOption === "new" + && noul(first, "new_option") >= 0.8 + && noul(candidate.answers, "new_option") >= 0.85 + && noul(candidate.answers, "planning_substance") >= 0.7 + && noul(candidate.answers, "duplicate") < 0.3; + if (!known && !linkedOption && !novel) continue; + let recommendation = directRecommendation(candidate.quote) + && choice(first, "act") === "proposal" + && noul(candidate.answers, "support") >= 0.8; + let commitment = directOwnedCommitment(candidate.quote) + && (known || linkedOption + ? choice(first, "act") === "commitment" + || noul(first, "explicit_resolution") >= 0.8 + && noul(candidate.answers, "explicit_resolution") >= 0.85 + : choice(first, "act") === "commitment" + && noul(first, "explicit_resolution") >= 0.8 + && noul(candidate.answers, "explicit_resolution") >= 0.8); + if (!recommendation && !commitment) continue; + let choices = choicesByThread.get(thread.id) ?? new Set(); + choices.add( + known || linkedOption + ? chosen! + : `new:${candidate.quote.trim().replace(/\s+/g, " ").toLowerCase()}`, + ); + choicesByThread.set(thread.id, choices); + } + let competingMessageTargets = new Set( + [...choicesByThread].filter(([, choices]) => choices.size > 1).map(([id]) => id), + ); + let run = { seenOptionLabels, sequentialChoiceQuestions, optionGroup, competingMessageTargets }; + context.candidateRun = run; + return run; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-stance-scoped.test.ts b/apps/server/src/conversation-plan/policy-candidate-stance-scoped.test.ts new file mode 100644 index 00000000..b8cdc93d --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-stance-scoped.test.ts @@ -0,0 +1,92 @@ +import { activeScopedSupport, currentScopedProposal } from "./events"; +import { expect, test } from "bun:test"; +import { applyInference, replay } from "./domain"; +import { + rawChoice, + scopedStanceInput, + stanceFrame, + withdrawScopedAgreement, +} from "./policy-candidate-stance.test-fixtures"; +import { runStance } from "./policy-candidate-stance"; + +test.each([{ participant: "Jules", agreed: false, expected: "scoped-proposal" }, { + participant: "Mina", + agreed: true, + expected: "scoped-proposal", +}, { participant: "Cora", agreed: false, expected: null }])( + "scoped objection ownership: $participant/$agreed", + ({ participant, agreed, expected }) => { + let input = scopedStanceInput(participant, agreed); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = stanceFrame(input); + let event = runStance(context, entry, role)!.proposed!; + expect(event).toMatchObject({ + type: "stance.changed", + position: "oppose", + optionId: "alpha", + scopedProposalId: expected, + }); + let accepted = applyInference(input.state, event, input.message); + expect(replay(accepted.events)).toEqual(accepted); + expect(context.events).toHaveLength(0); + }, +); +test("support leaves scopedProposalId null even for the scoped proposer", () => { + let { context, entry, role } = stanceFrame(scopedStanceInput("Jules", false, "support")); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ + position: "support", + scopedProposalId: null, + }); +}); +test.each(["proposal", "agreement"])( + "current history must retain the exact %s for scoped ownership", + removed => { + let { context, entry, role } = stanceFrame(scopedStanceInput("Mina", true)); + let captured = role.thread; + context.working = { + ...context.working, + events: context.working.events.filter(event => + removed === "proposal" ? event.id !== "scoped-proposal" : event.id !== "scoped-assent" + ), + }; + expect(runStance(context, entry, role)!.proposed).toMatchObject({ scopedProposalId: null }); + expect(role.thread).toBe(captured); + }, +); +test.each(["option", "chosen_option"])( + "scoped ownership requires confident %s despite raw fallback", + answer => { + let input = scopedStanceInput(); + input.candidates[0]!.answers[answer] = rawChoice("alpha"); + let { context, entry, role } = stanceFrame(input); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ + optionId: "alpha", + scopedProposalId: null, + }); + }, +); +test("restoring a fresh card map cannot replace a mismatched captured scoped card", () => { + let input = scopedStanceInput(); + let card = input.linkedCards!.get("provider")!; + input.linkedCards = new Map([["provider", { ...card, cardId: "stale" }]]); + let { context, entry, role } = stanceFrame(input); + context.input.linkedCards = new Map([["provider", card]]); + expect(role.linkedCard!.cardId).toBe("stale"); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ scopedProposalId: null }); +}); + +test("historical agreement still establishes ownership after active support withdrawal", () => { + let input = scopedStanceInput("Mina", true); + withdrawScopedAgreement(input); + expect(replay(input.state.events)).toEqual(input.state); + let proposal = currentScopedProposal(input.state.threads[0]!, input.state.events)!; + expect( + activeScopedSupport(input.state.events, proposal).some(event => + event.source.author.kind === "member" && event.source.author.handle === "Mina" + ), + ).toBe(false); + let { context, entry, role } = stanceFrame(input); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ + scopedProposalId: "scoped-proposal", + }); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-stance.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-stance.test-fixtures.ts new file mode 100644 index 00000000..39b6694b --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-stance.test-fixtures.ts @@ -0,0 +1,107 @@ +import { applyEvent } from "./events"; +import { applyInference } from "./domain"; +import { + contributionFrame, + contributionInput, + linkContributionCard, +} from "./policy-candidate-contribution.test-fixtures"; +import { scopedInput } from "./policy-candidate-scoped.test-fixtures"; +import { confidentChoice, message } from "./policy-initial.test-fixtures"; +import type { PolicyInput } from "./policy-types"; + +// Offline fixtures for archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/policy.ts consequent1433–1530. +// Original complete downstream callbacks remain assigned whole. +export function stanceInput( + role = "support", + quote = role === "support" ? "I support Alpha." : "I oppose Alpha.", +): PolicyInput { + let input = contributionInput(role, quote); + linkContributionCard(input); + return input; +} +export function decidedInput(probability = 0.68) { + let input = stanceInput("objection"); + input.state = applyEvent(input.state, { + id: "decide-provider", + type: "decision.recorded", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Jules" }, + at: 1000, + explicit: true, + text: "Use Alpha.", + optionId: "alpha", + }); + input.candidates[0]!.answers.material_objection = { type: "noul", noul: probability }; + return input; +} +export function scopedStanceInput(participant = "Jules", agreed = false, role = "objection") { + let input = stanceInput(role), saved = scopedInput(true); + input.state = saved.state; + input.linkedCards = saved.linkedCards; + input.message.author = { kind: "member", handle: participant }; + if (agreed) { + let assent = message("scoped-assent", "yep, Alpha for a spike.", "Mina"); + input.state = applyInference(input.state, { + id: "scoped-assent", + type: "scoped-choice.agreed", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: assent.id, + author: { kind: "member", handle: "Mina" }, + quote: assent.text, + start: 0, + end: assent.text.length, + role: "support", + }, + proposalId: "scoped-proposal", + cardId: input.state.threads[0]!.questionnaireId!, + optionId: "alpha", + label: "Alpha", + scope: "spike", + }, assent); + } + return input; +} +export function rawChoice(id: string, probability = 0.4) { + return { + type: "choice" as const, + choice: id, + confidence: probability, + probabilities: { [id]: probability, none: 0.35, other: 0.25 }, + }; +} +export function stanceFrame(input = stanceInput()) { + return contributionFrame(input); +} +export { confidentChoice }; + +export function withdrawScopedAgreement(input: PolicyInput) { + let saved = message("scoped-withdrawal", "I withdraw my spike support.", "Mina"); + input.state = applyInference(input.state, { + id: "scoped-withdrawal", + type: "stance.changed", + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1000, + source: { + messageId: saved.id, + author: { kind: "member", handle: "Mina" }, + quote: saved.text, + start: 0, + end: saved.text.length, + role: "withdrawal", + }, + optionId: "alpha", + scopedProposalId: "scoped-proposal", + position: "neutral", + }, saved); +} diff --git a/apps/server/src/conversation-plan/policy-candidate-stance.test.ts b/apps/server/src/conversation-plan/policy-candidate-stance.test.ts new file mode 100644 index 00000000..7f043962 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-stance.test.ts @@ -0,0 +1,175 @@ +import { expect, test } from "bun:test"; +import { applyInference, replay } from "./domain"; +import { + confidentChoice, + decidedInput, + rawChoice, + stanceFrame, + stanceInput, +} from "./policy-candidate-stance.test-fixtures"; +import { runStance } from "./policy-candidate-stance"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import { linkContributionCard } from "./policy-candidate-contribution.test-fixtures"; +import { stableId } from "./policy-identity"; + +test.each(["support", "objection"])( + "%s returns the exact regular stance without applying it", + name => { + let input = stanceInput(name); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = stanceFrame(input); + expect(role.role).toBe(name); + let before = structuredClone(context.working); + let event = runStance(context, entry, role)!.proposed!; + expect(event).toMatchObject({ + type: "stance.changed", + source: { role: name, quote: input.message.text }, + position: name === "support" ? "support" : "oppose", + optionId: "alpha", + scopedProposalId: null, + }); + let accepted = applyInference(input.state, event, input.message); + expect(replay(accepted.events)).toEqual(accepted); + expect(context.working).toEqual(before); + expect(context.events).toHaveLength(0); + expect(context.reviews).toHaveLength(0); + expect(entry.outcome.eventIds).toHaveLength(0); + }, +); +test.each([0.679, 0.68])("decided material objection threshold %s", probability => { + let input = decidedInput(probability); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = stanceFrame(input); + let event = runStance(context, entry, role)!.proposed!; + if (probability === 0.68) { + expect(event).toMatchObject({ + type: "candidate.proposed", + source: { role: "reopening" }, + candidate: { + id: stableId(input.channelId, input.message.id, 0, "reopening-candidate"), + kind: "reopening", + text: input.message.text, + }, + }); + expect(context.reviews).toHaveLength(1); + } else { + expect(event.type).toBe("stance.changed"); + expect(context.reviews).toHaveLength(0); + } + expect(context.events).toHaveLength(0); +}); +test("a second reopening review is handled before constructing a proposal", () => { + let { context, entry, role } = stanceFrame(decidedInput()); + context.reviews.push({ id: "existing", kind: "reopening", targetId: "provider" }); + expect(runStance(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("reopening review already proposed from this message"); + expect(context.reviews).toHaveLength(1); +}); +test("missing captured thread is handled without a stance", () => { + let input = stanceInput(); + input.candidates[0]!.answers.thread = confidentChoice("missing"); + let { context, entry, role } = stanceFrame(input); + expect(runStance(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("stance target or human authority unclear"); +}); +test("late agent author fails the original human guard", () => { + let { context, entry, role } = stanceFrame(); + context.message.author = { kind: "agent" }; + expect(runStance(context, entry, role)).toBeUndefined(); + expect(entry.outcome.status).toBe("review"); +}); +test.each(["wrong-card", "substring", "duplicate-name", "wrong-text", "wrong-target", "unnamed"])( + "captured card boundary: %s", + variant => { + let input = stanceInput(); + let card = input.linkedCards!.get("provider")!; + if (variant === "wrong-card") { + input.linkedCards = new Map([["provider", { + ...card, + cardId: "wrong", + }]]); + } + if (variant === "substring" || variant === "duplicate-name") { + input.linkedCards = new Map([["provider", { + ...card, + options: [...card.options, { + id: "other", + label: variant === "substring" ? "Alpha Pro" : "Alpha", + }], + }]]); + } + if (variant === "wrong-text") { + input.linkedCards = new Map([["provider", { + ...card, + options: [{ id: "alpha", label: "Beta" }], + }]]); + input.message.text = "I support Beta."; + Object.assign(input.candidates[0]!, { + quote: input.message.text, + end: input.message.text.length, + }); + } + if (variant === "wrong-target") input.first.thread_target = confidentChoice("other"); + if (variant === "unnamed") { + input.message.text = "I agree."; + Object.assign(input.candidates[0]!, { + quote: input.message.text, + end: input.message.text.length, + }); + } + let { context, entry, role } = stanceFrame(input); + let event = runStance(context, entry, role)!.proposed!; + expect(event).toMatchObject({ + type: "stance.changed", + optionId: undefined, + scopedProposalId: null, + }); + expect(Object.hasOwn(event, "optionId")).toBe(true); + }, +); +test.each(["alpha", "beta"])("raw option fallback preserves %s despite low confidence", option => { + let input = stanceInput(); + input.candidates[0]!.answers.option = rawChoice(option); + input.candidates[0]!.answers.chosen_option = rawChoice("alpha"); + let { context, entry, role } = stanceFrame(input); + expect(role.option).toBeUndefined(); + expect(role.chosenOption).toBeUndefined(); + let event = runStance(context, entry, role)!.proposed!; + expect(event).toMatchObject({ optionId: option === "alpha" ? "alpha" : undefined }); +}); +test("captured chosen-option mismatch clears a named option", () => { + let input = stanceInput(); + input.candidates[0]!.answers.chosen_option = confidentChoice("beta"); + let { context, entry, role } = stanceFrame(input); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ optionId: undefined }); +}); +test("fresh input card replacement does not replace the earlier captured card", () => { + let { context, entry, role } = stanceFrame(); + let card = role.linkedCard; + context.input.linkedCards = new Map(); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ optionId: "alpha" }); + expect(role.linkedCard).toBe(card); +}); + +test("reverse substring collision rejects one indirectly named card label", () => { + let label = "Start with a durable queue."; + let input = stanceInput("support", "I support a durable queue."); + input.state = stateWithOption("Which provider?", "provider", "alpha", label); + linkContributionCard(input); + let card = input.linkedCards!.get("provider")!; + input.linkedCards = new Map([["provider", { + ...card, + options: [{ id: "alpha", label }, { id: "other", label: "Start with a durable" }], + }]]); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = stanceFrame(input); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ optionId: undefined }); +}); +test("raw chosen-option conflict blocks fallback independently of raw option", () => { + let input = stanceInput(); + input.candidates[0]!.answers.option = rawChoice("alpha"); + input.candidates[0]!.answers.chosen_option = rawChoice("beta"); + let { context, entry, role } = stanceFrame(input); + expect(role.chosenOption).toBeUndefined(); + expect(runStance(context, entry, role)!.proposed).toMatchObject({ optionId: undefined }); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-stance.ts b/apps/server/src/conversation-plan/policy-candidate-stance.ts new file mode 100644 index 00000000..30334a60 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-stance.ts @@ -0,0 +1,121 @@ +import { currentScopedProposal } from "./events"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import { namesCardOption, namesStanceOption } from "./policy-cues"; +import { stableId } from "./policy-identity"; +import { choice, noul } from "./policy-scoring"; +import type { Event } from "./policy-types"; + +/** Invoke only for captured support/objection; a result progresses without application. */ +export function runStance( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { channelId, message, first, reviews } = context; + let { index, candidate, outcome } = entry; + let { thread, option, linkedCard, chosenOption } = frame; + let role = frame.role as "support" | "objection"; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + let proposed: Event | undefined = undefined; + + if (!thread || message.author.kind !== "member") { + outcome.status = "review"; + outcome.gate = "stance target or human authority unclear"; + return undefined; + } + if ( + thread.status === "decided" && role === "objection" + && noul(candidate.answers, "material_objection") >= 0.68 + ) { + if (reviews.some((item) => item.kind === "reopening" && item.targetId === thread.id)) { + outcome.status = "review"; + outcome.gate = "reopening review already proposed from this message"; + return undefined; + } + let id = stableId(channelId, message.id, index, "reopening-candidate"); + proposed = { + ...base(thread.id, "candidate.proposed"), + type: "candidate.proposed", + source: source("reopening"), + candidate: { id, kind: "reopening", text: candidate.quote }, + }; + reviews.push({ id, kind: "reopening", targetId: thread.id }); + } else { + let existingOption = option && !["new", "none"].includes(option) + && thread.contributions.some((item) => item.id === option && item.kind === "option") + ? option + : undefined; + if (linkedCard) { + let named = linkedCard.options.filter(item => namesStanceOption(candidate.quote, item.label)); + let selected = named.length === 1 ? named[0] : undefined; + let unique = selected + && !linkedCard.options.some(item => + item.id !== selected.id + && (namesCardOption(item.label, selected.label) + || namesCardOption(selected.label, item.label)) + ) + && thread.contributions.some(item => + item.kind === "option" && item.id === selected.id + && (item.displayLabel ?? item.text) === selected.label + ); + if ( + !selected || !unique || linkedCard.cardId !== thread.questionnaireId + || choice(first, "thread_target") !== thread.id + ) existingOption = undefined; + else if (existingOption && existingOption !== selected.id) { + existingOption = undefined; + } else if (!existingOption) { + if ( + ["none", selected.id].includes( + candidate.answers.option?.type === "choice" + ? candidate.answers.option.choice + : "none", + ) + && ["none", selected.id].includes( + candidate.answers.chosen_option?.type === "choice" + ? candidate.answers.chosen_option.choice + : "none", + ) + ) existingOption = selected.id; + } + } + if (chosenOption && chosenOption !== "none" && chosenOption !== existingOption) { + existingOption = undefined; + } + let scopedProposalId: string | null = null; + let scoped = thread.pendingScopedChoice; + let participant = message.author.handle; + if ( + role === "objection" && scoped?.scope === "spike" + && thread.questionnaireId === scoped.cardId + && choice(candidate.answers, "option") === scoped.optionId + && choice(candidate.answers, "chosen_option") === scoped.optionId + && linkedCard?.cardId === scoped.cardId + && linkedCard.options.some(item => item.id === scoped.optionId && item.label === scoped.label) + ) { + let proposal = currentScopedProposal(thread, working.events); + if ( + proposal && (participant === scoped.proposer + || working.events.some(item => + item.type === "scoped-choice.agreed" && item.proposalId === proposal.id + && item.source.author.kind === "member" + && item.source.author.handle === participant + )) + ) scopedProposalId = proposal.id; + } + proposed = { + ...base(thread.id, "stance.changed"), + type: "stance.changed", + source: source(role), + position: role === "support" ? "support" : "oppose", + optionId: existingOption, + scopedProposalId, + }; + } + + return { proposed }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-verification.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate-verification.test-fixtures.ts new file mode 100644 index 00000000..4473bb78 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-verification.test-fixtures.ts @@ -0,0 +1,65 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import { beginCandidate } from "./policy-candidate-entry"; +import { candidateContext, roleInput } from "./policy-candidate-entry.test-fixtures"; +import { captureCandidateRole } from "./policy-candidate-role"; +import { message } from "./policy-initial.test-fixtures"; +import { stateWithOption } from "./policy-terminal.test-fixtures"; +import type { PolicyInput } from "./policy-types"; + +// Pure domain history for archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// policy.ts factories1092–1107 and early verification/condition1108–1159. +export function verificationInput(): PolicyInput { + let input = roleInput("reason", "Backups are verified."); + input.state = stateWithOption("Which provider?", "provider", "alpha", "Alpha"); + let citation = ( + saved: Chat.Entry, + role: ConversationPlan.SourceRole, + ): ConversationPlan.SourceRef => ({ + messageId: saved.id, + author: saved.author as ConversationPlan.SourceAuthor, + quote: saved.text, + start: 0, + end: saved.text.length, + role, + }); + let historyBase = (id: string) => ({ + id, + threadId: "provider", + observedThreadVersion: input.state.threads[0]!.version, + origin: "classifier" as const, + actor: { kind: "classifier" as const }, + at: 1000, + }); + let proposal = message("proposal-message", "Let's use Alpha.", "Jules"); + input.state = applyInference(input.state, { + ...historyBase("proposal-event"), + type: "settle.suggested", + source: citation(proposal, "resolution"), + optionId: "alpha", + }, proposal); + let deferred = message("defer-message", "Let's verify backups before choosing.", "Jules"); + input.state = applyInference(input.state, { + ...historyBase("withdrawal-event"), + type: "stance.changed", + position: "neutral", + source: citation(deferred, "withdrawal"), + optionId: "alpha", + scopedProposalId: null, + }, deferred); + input.state = applyInference(input.state, { + ...historyBase("defer-event"), + type: "settle.deferred", + proposalId: "proposal-event", + source: citation(deferred, "constraint"), + }, deferred); + input.first.evidence = { type: "noul", noul: 0.95 }; + return input; +} + +export function verificationFrame(input = verificationInput()) { + let context = candidateContext(input); + let entry = beginCandidate(context, 0, input.candidates[0]!)!; + let role = captureCandidateRole(context, entry)!; + return { context, entry, role }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate-verification.test.ts b/apps/server/src/conversation-plan/policy-candidate-verification.test.ts new file mode 100644 index 00000000..c3741b88 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-verification.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from "bun:test"; +import { replay } from "./domain"; +import { applyEvent } from "./events"; +import { runCandidateVerification } from "./policy-candidate-verification"; +import { + verificationFrame, + verificationInput, +} from "./policy-candidate-verification.test-fixtures"; +import { roleInput } from "./policy-candidate-entry.test-fixtures"; +import { activeSettleDeferral } from "./preference"; + +test("valid deferred domain history resumes exact proposal and marks its outcome before skipping", () => { + let input = verificationInput(); + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = verificationFrame(input); + let before = structuredClone(input); + expect(runCandidateVerification(context, entry, role)).toBeUndefined(); + let event = context.events[0]!; + expect(event.type).toBe("settle.resumed"); + if (event.type !== "settle.resumed") throw new Error("expected resume"); + expect(event.proposalId).toBe("proposal-event"); + expect(event.deferredEventId).toBe("defer-event"); + expect(entry.outcome.eventIds).toEqual([event.id]); + expect(entry.outcome.status).toBe("accepted"); + expect(entry.outcome.gate).toBe("accepted"); + expect(activeSettleDeferral(context.working.threads[0]!, context.working.events)).toBeUndefined(); + expect(replay(context.working.events)).toEqual(context.working); + expect(input).toEqual(before); +}); + +test.each(["weak evidence", "wrong chosen option", "wrong subject"])( + "verification %s falls through without mutation", + kind => { + let input = verificationInput(); + if (kind === "weak evidence") input.first.evidence = { type: "noul", noul: 0.79 }; + if (kind === "wrong chosen option") { + input.candidates[0]!.answers.chosen_option = { + type: "choice", + choice: "other", + confidence: 0.95, + probabilities: { other: 0.95, none: 0.05 }, + }; + } + if (kind === "wrong subject") { + input.message.text = "Latency is verified."; + input.candidates[0]!.quote = input.message.text; + input.candidates[0]!.end = input.message.text.length; + } + let { context, entry, role } = verificationFrame(input); + let working = context.working; + expect(runCandidateVerification(context, entry, role)).toEqual({ spikeLabel: undefined }); + expect(context.working).toBe(working); + expect(context.events).toHaveLength(0); + }, +); + +test("a discarded working thread rejects verification while captured thread history stays open", () => { + let { context, entry, role } = verificationFrame(); + context.working = applyEvent(context.working, { + id: "discard", + type: "thread.discarded", + threadId: "provider", + observedThreadVersion: context.working.threads[0]!.version, + origin: "human", + actor: { kind: "member", handle: "Mina" }, + at: 1001, + }); + let working = context.working; + expect(role.thread!.status).toBe("exploring"); + expect(runCandidateVerification(context, entry, role)).toBeUndefined(); + expect(context.working).toBe(working); + expect(entry.outcome.gate).toBe("verification needs review"); + expect(entry.outcome.status).toBe("review"); + expect(context.events).toHaveLength(0); +}); + +test.each([false, true])( + "spike preference caches label or skips its immediate condition: %s", + conditional => { + let input = roleInput("support", "I'd pick Alpha for a spike;"); + if (conditional) input.message.text += " if latency stays low."; + let { context, entry, role } = verificationFrame(input); + let result = runCandidateVerification(context, entry, role); + if (conditional) { + expect(result).toBeUndefined(); + expect(entry.outcome.status).toBe("review"); + expect(entry.outcome.gate).toBe("spike choice has an immediate condition"); + } else { + expect(result).toEqual({ spikeLabel: "Alpha" }); + entry.candidate.quote = "I'd pick Beta for a spike;"; + expect(result!.spikeLabel).toBe("Alpha"); + } + expect(context.events).toHaveLength(0); + }, +); diff --git a/apps/server/src/conversation-plan/policy-candidate-verification.ts b/apps/server/src/conversation-plan/policy-candidate-verification.ts new file mode 100644 index 00000000..dfd1d5d5 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-verification.ts @@ -0,0 +1,80 @@ +import { applyInference } from "./domain"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import { immediateSpikeCondition, OWNED_QUOTE_MIN } from "./policy-cues"; +import { choice, noul } from "./policy-scoring"; +import type { Event } from "./policy-types"; +import { activeSettleDeferral } from "./preference"; +import { spikePreferenceLabel } from "./validation-fields"; + +/** Undefined handles/skips this candidate; continuation carries only its cached spike label. */ +export function runCandidateVerification( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +) { + let { message, first, events } = context; + let { index, candidate, outcome } = entry; + let { thread, role } = frame; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + try { + let deferred = thread && activeSettleDeferral(thread, working.events); + let subject = deferred?.source.quote.match( + /\b(?:verify|check|test)\s+(?:the\s+)?([\p{L}][\p{L}\p{N}-]*)/iu, + )?.[1]?.replace(/s$/i, ""); + let verifiedSubject = subject && new RegExp( + `^(?:the\\s+[\\p{L}\\p{N}-]+\\s+test\\s+passed\\s+and\\s+)?(?:` + + `(?:the\\s+)?${subject}s?\\s+(?:(?:have|has|had)\\s+been|is|are|was|were)\\s+verified` + + `|we\\s+(?:have\\s+)?verified\\s+(?:the\\s+)?${subject}s?)` + + `[.!]?$`, + "iu", + ).test(candidate.quote.trim()); + let verificationOption = choice(candidate.answers, "chosen_option"); + let mentionedOption = choice(candidate.answers, "option"); + if ( + thread && deferred && thread.pendingSettle && message.author.kind === "member" + && (role === "reason" || role === "resolution") + && choice(first, "thread_target") === thread.id + && noul(first, `c${index}_owned_unretracted`) >= OWNED_QUOTE_MIN + && (noul(first, "evidence") >= 0.8 || noul(first, "explicit_resolution") >= 0.8) + && (noul(candidate.answers, "planning_substance") >= 0.8 + || noul(candidate.answers, "explicit_resolution") >= 0.85) + && (verificationOption === "none" + || verificationOption === thread.pendingSettle.optionId) + && (mentionedOption === "none" + || mentionedOption === thread.pendingSettle.optionId) + && verifiedSubject + ) { + let resumed: Event = { + ...base(thread.id, "settle.resumed"), + type: "settle.resumed", + proposalId: deferred.proposalId, + deferredEventId: deferred.id, + source: source("verification"), + }; + try { + working = applyInference(working, resumed, message); + events.push(resumed); + outcome.eventIds.push(resumed.id); + outcome.status = "accepted"; + outcome.gate = "accepted"; + } catch { + outcome.status = "review"; + outcome.gate = "verification needs review"; + } + return undefined; + } + let spikeLabel = spikePreferenceLabel(candidate.quote); + if (spikeLabel && immediateSpikeCondition(message.text, candidate.end)) { + outcome.status = "review"; + outcome.gate = "spike choice has an immediate condition"; + return undefined; + } + return { spikeLabel }; + } finally { + context.working = working; + } +} diff --git a/apps/server/src/conversation-plan/policy-candidate-withdrawal.test.ts b/apps/server/src/conversation-plan/policy-candidate-withdrawal.test.ts new file mode 100644 index 00000000..116c1ecf --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-withdrawal.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; +import { replay } from "./domain"; +import { + ordinaryFrame, + ordinaryInput, + withdrawCurrent, +} from "./policy-candidate-ordinary.test-fixtures"; +import { runWithdrawal } from "./policy-candidate-withdrawal"; + +test.each([0.8, 0.799])("withdrawal threshold %s retains a valid owned proposal", probability => { + let input = ordinaryInput(); + input.candidates[0]!.answers.withdraws_pending_settle = { type: "noul", noul: probability }; + expect(replay(input.state.events)).toEqual(input.state); + let { context, entry, role } = ordinaryFrame(input); + expect(role.role).toBe("withdrawal"); + let before = structuredClone(context.working); + let result = runWithdrawal(context, entry, role); + if (probability === 0.8) { + expect(result?.proposed).toMatchObject({ + type: "stance.changed", + optionId: "alpha", + position: "neutral", + source: { role: "withdrawal" }, + }); + } else { + expect(result).toBeUndefined(); + expect(entry.outcome.gate).toBe("pending withdrawal needs a clear owned proposal"); + } + expect(context.working).toEqual(before); + expect(context.events).toHaveLength(0); +}); +test.each(["If I withdraw that choice.", "I withdraw that choice?"])( + "conditional/question withdrawal is handled: %s", + quote => { + let input = ordinaryInput(); + input.message.text = quote; + Object.assign(input.candidates[0]!, { quote, end: quote.length }); + let { context, entry, role } = ordinaryFrame(input); + expect(runWithdrawal(context, entry, role)).toBeUndefined(); + expect(entry.outcome.status).toBe("review"); + }, +); +test("withdrawal keeps captured pending/thread while searching current event history", () => { + let input = ordinaryInput(); + let { context, entry, role } = ordinaryFrame(input); + context.working = withdrawCurrent(input); + expect(replay(context.working.events)).toEqual(context.working); + expect(role.pending).toBeDefined(); + expect(runWithdrawal(context, entry, role)?.proposed?.type).toBe("stance.changed"); +}); +test("missing current authored proposal rejects the previously captured pending", () => { + let { context, entry, role } = ordinaryFrame(); + context.working = { + ...context.working, + events: context.working.events.filter(event => event.type !== "settle.suggested"), + }; + expect(runWithdrawal(context, entry, role)).toBeUndefined(); + expect(entry.outcome.status).toBe("review"); +}); + +test("another member cannot withdraw the captured owned proposal", () => { + let input = ordinaryInput(); + input.message.author = { kind: "member", handle: "Mina" }; + let { context, entry, role } = ordinaryFrame(input); + expect(runWithdrawal(context, entry, role)).toBeUndefined(); + expect(entry.outcome.gate).toBe("pending withdrawal needs a clear owned proposal"); + expect(context.events).toHaveLength(0); +}); diff --git a/apps/server/src/conversation-plan/policy-candidate-withdrawal.ts b/apps/server/src/conversation-plan/policy-candidate-withdrawal.ts new file mode 100644 index 00000000..c3656664 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate-withdrawal.ts @@ -0,0 +1,51 @@ +import type { CandidateEntry } from "./policy-candidate-entry"; +import { createCandidateFactories } from "./policy-candidate-factories"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import type { Event } from "./policy-types"; +import { noul } from "./policy-scoring"; + +/** Invoke only for its matching captured role; undefined skips, a result continues without application. */ +export function runWithdrawal( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { message } = context; + let { candidate, outcome } = entry; + let { pending, thread, option } = frame; + let working = context.working; + let { source, base } = createCandidateFactories(context, entry, () => working); + let proposed: Event | undefined = undefined; + + let acceptedProposal = pending + && working.events.findLast(event => + event.type === "settle.suggested" && event.threadId === thread?.id + && event.optionId === pending.optionId + && event.source.messageId === pending.messageId + && event.source.author.kind === "member" + && event.source.author.handle === pending.proposer + ); + if ( + !thread || thread.status === "decided" || thread.status === "discarded" + || message.author.kind !== "member" || !pending || !acceptedProposal + || pending.proposer !== message.author.handle || option !== pending.optionId + || noul(candidate.answers, "withdraws_pending_settle") < 0.8 + || /\b(?:if|unless|might|would|could)\b/i.test(candidate.quote) + || candidate.quote.trim().endsWith("?") + ) { + outcome.status = "review"; + outcome.gate = "pending withdrawal needs a clear owned proposal"; + return undefined; + } + proposed = { + ...base(thread.id, "stance.changed"), + type: "stance.changed", + source: source("withdrawal"), + optionId: pending.optionId, + scopedProposalId: null, + position: "neutral", + }; + + return { proposed }; +} diff --git a/apps/server/src/conversation-plan/policy-candidate.test-fixtures.ts b/apps/server/src/conversation-plan/policy-candidate.test-fixtures.ts new file mode 100644 index 00000000..434231ba --- /dev/null +++ b/apps/server/src/conversation-plan/policy-candidate.test-fixtures.ts @@ -0,0 +1,64 @@ +import { expect } from "bun:test"; +import { initialState } from "./domain"; +import { createPolicyContext, type PolicyContext } from "./policy-context"; +import { runInitialTerminals } from "./policy-initial-terminals"; +import { runRemainingTerminals } from "./policy-remaining-terminals"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { addThreadWithOption, stateWithOption } from "./policy-terminal.test-fixtures"; +import type { PolicyInput } from "./policy-types"; + +// Setup-only fixtures for archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// policy.ts statements800–876. Original full-policy callbacks remain deferred whole. +export function groupInput(quotes: readonly string[] = ["Alpha", "Beta", "Gamma"]): PolicyInput { + let text = quotes.join(" "); + let offset = 0; + return { + channelId: "channel", + message: message("candidate-setup", text), + state: initialState(), + first: { + ...first({ new_question: 0.95, c3_owned_unretracted: 0.95 }), + act: confidentChoice("question"), + thread_target: confidentChoice("new"), + }, + candidates: quotes.map(quote => { + let start = offset; + offset += quote.length + 1; + return { + start, + end: start + quote.length, + quote, + answers: { ...follow({ role: "option", thread: "new" }) }, + }; + }), + }; +} + +export function choiceInput(ids: readonly string[] = ["alpha", "beta"]): PolicyInput { + let input = groupInput(ids.map(id => `We should use ${id}.`)); + input.state = stateWithOption("Which provider?", "provider", "alpha", "Alpha"); + input.state = addThreadWithOption(input.state, "other", "Which other provider?", "beta", "Beta"); + let other = input.state.threads.find(thread => thread.id === "other")!; + input.state.threads[0]!.contributions.push({ ...other.contributions[0]!, id: "beta" }); + input.first.act = confidentChoice("proposal"); + input.first.thread_target = confidentChoice("provider"); + input.first.new_option = { type: "noul", noul: 0.95 }; + for (let [index, candidate] of input.candidates.entries()) { + candidate.answers = { + ...follow({ role: "support", thread: "provider" }), + chosen_option: confidentChoice(ids[index]!), + option: confidentChoice(ids[index]!), + new_option: { type: "noul", noul: 0.95 }, + planning_substance: { type: "noul", noul: 0.95 }, + duplicate: { type: "noul", noul: 0.05 }, + }; + } + return input; +} + +export function prepareCandidateContext(input: PolicyInput): PolicyContext { + let context = createPolicyContext(input); + expect(runInitialTerminals(context)).toBeUndefined(); + expect(runRemainingTerminals(context)).toBeUndefined(); + return context; +} diff --git a/apps/server/src/conversation-plan/policy-compound.ts b/apps/server/src/conversation-plan/policy-compound.ts new file mode 100644 index 00000000..a51bf9c2 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-compound.ts @@ -0,0 +1,187 @@ +import { applyInference } from "./domain"; +import type { DirectFacts, PolicyContext } from "./policy-context"; +import { OWNED_QUOTE_MIN } from "./policy-cues"; +import { stableId } from "./policy-identity"; +import { choice, moderateChoice, noul, roleChoice } from "./policy-scoring"; +import type { Event, PolicyResult } from "./policy-types"; +import { + explicitListQuotes, + isAttributedCompoundDecision, + isExplicitCompoundDecision, + multiAlternativeQuotes, +} from "./quotes"; + +export function questionListCorroboration( + context: PolicyContext, + facts: DirectFacts, +): PolicyResult | undefined { + let { input, message, first, events, reviews } = context; + let { questionList, exactDirectAlternatives, directBounds } = facts; + let questionBeforeOptions = message.text.indexOf("?") >= 0 + && message.text.indexOf("?") < (input.candidates[0]?.start ?? 0); + let listed = explicitListQuotes(message.text); + if ( + (listed.length >= 3 || input.candidates.length >= 3) + && /,\s*or\s+/i.test(message.text) + && (questionBeforeOptions + || !message.text.trimEnd().endsWith("?") + && multiAlternativeQuotes(message.text).length === 0) + ) { + let exact = listed.length === input.candidates.length + && listed.every((quote, index) => + quote.quote === input.candidates[index]?.quote + && quote.start === input.candidates[index]?.start + && quote.end === input.candidates[index]?.end + ); + let open = input.state.threads.filter(thread => + !["decided", "discarded"].includes(thread.status) + ); + let thread = open.length === 1 ? open[0] : undefined; + let card = thread && input.linkedCards?.get(thread.id); + let bareList = !questionBeforeOptions; + let supported = !bareList || message.author.kind === "member" && !!thread + && (!card || card.options.length === 0) + && choice(first, "thread_target") === thread.id + && noul(first, "new_option") >= 0.8 + && input.candidates.every((candidate, index) => + noul(first, `c${index}_owned_unretracted`) >= OWNED_QUOTE_MIN + && roleChoice(candidate.answers, first) === "option" + && choice(candidate.answers, "thread") === thread.id + && choice(candidate.answers, "option") === "new" + && noul(candidate.answers, "new_option") >= 0.8 + && noul(candidate.answers, "duplicate") < 0.3 + && !thread.contributions.some(item => + item.kind === "option" + && item.text.trim().toLowerCase() === candidate.quote.trim().toLowerCase() + ) + ); + if (!exact || !supported) { + return { + events, + candidates: reviews, + outcomes: input.candidates.map(candidate => ({ + start: candidate.start, + end: candidate.end, + status: "review", + gate: "alternative list source needs review", + eventIds: [], + })), + policyGate: "alternative list source needs review", + }; + } + } + if ( + questionList && !(message.author.kind === "member" + && exactDirectAlternatives && directBounds + && input.candidates.every((candidate, index) => + noul(first, `c${index}_owned_unretracted`) >= 0.8 + && ["role", "thread", "option"].every(key => + candidate.answers[key]?.type === "choice" + && candidate.answers[key].confidence >= 0.8 + ) + && moderateChoice(candidate.answers, "role", "option", 0.8) + && moderateChoice(candidate.answers, "thread", "new", 0.8) + && moderateChoice(candidate.answers, "option", "new", 0.8) + && candidate.answers.new_option?.type === "noul" + && noul(candidate.answers, "new_option") >= 0.85 + && candidate.answers.duplicate?.type === "noul" + && noul(candidate.answers, "duplicate") <= 0.2 + )) + ) { + return { + events, + candidates: reviews, + outcomes: input.candidates.map(candidate => ({ + start: candidate.start, + end: candidate.end, + status: "review", + gate: "question alternatives need candidate corroboration", + eventIds: [], + })), + policyGate: "question alternatives need candidate corroboration", + }; + } +} + +export function compoundAttribution(context: PolicyContext): PolicyResult | undefined { + let { input, message, events, reviews } = context; + if (isAttributedCompoundDecision(message.text, input.candidates)) { + return { + events, + candidates: reviews, + outcomes: input.candidates.map(candidate => ({ + start: candidate.start, + end: candidate.end, + status: "review", + gate: "compound decision attribution unclear", + eventIds: [], + })), + policyGate: "compound decision attribution unclear", + }; + } +} + +export function compoundOpenings(context: PolicyContext): PolicyResult | undefined { + let { input, channelId, message, first, reviews } = context; + if ( + message.author.kind === "member" && input.state.threads.length === 0 + && isExplicitCompoundDecision(message.text, input.candidates) + && noul(first, "new_question") >= 0.55 + && noul(first, "enough_purpose") >= 0.8 + && choice(first, "act") === "question" + && choice(first, "thread_target") === "new" + && input.candidates.every((candidate, index) => + noul(first, `c${index}_owned_unretracted`) >= 0.8 + && moderateChoice(candidate.answers, "role", "question", 0.55) + && choice(candidate.answers, "thread") === "new" + && noul(candidate.answers, "duplicate") < 0.3 + ) + ) { + let staged = input.state; + let openings: Event[] = []; + for (let [index, candidate] of input.candidates.entries()) { + let threadId = `thread:${stableId(channelId, message.id, index, "thread").slice(11)}`; + let opening: Event = { + id: stableId(channelId, message.id, index, "thread.opened"), + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author, + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + role: "question", + }, + question: candidate.quote, + }; + try { + staged = applyInference(staged, opening, message); + } catch { + openings = []; + break; + } + openings.push(opening); + } + if (openings.length === input.candidates.length) { + return { + events: openings, + selectedTarget: openings[0]!.threadId, + candidates: reviews, + outcomes: input.candidates.map((candidate, index) => ({ + start: candidate.start, + end: candidate.end, + status: "accepted", + gate: "accepted", + eventIds: [openings[index]!.id], + targetId: openings[index]!.threadId, + })), + policyGate: "explicit compound questions accepted", + }; + } + } +} diff --git a/apps/server/src/conversation-plan/policy-context.ts b/apps/server/src/conversation-plan/policy-context.ts new file mode 100644 index 00000000..2db76059 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-context.ts @@ -0,0 +1,96 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { JevAnswer } from "./jev"; +import type { Event, PolicyInput, PolicyResult, State } from "./policy-types"; +import { directAlternativeQuotes, type QuoteCandidate } from "./quotes"; +import { assertQuoteBudget } from "./quote-budget"; + +export type DirectFacts = { + optionLabels: string[]; + directAlternatives: ReturnType; + exactDirectAlternatives: boolean; + questionKey: string; + matchingThreads: ConversationPlan.Thread[]; + directBounds: boolean; + questionList: boolean; +}; +export type CandidateRun = { + seenOptionLabels: Set; + sequentialChoiceQuestions: boolean; + optionGroup: string | undefined; + competingMessageTargets: Set; +}; +export type PolicyContext = { + input: PolicyInput; + channelId: string; + message: Chat.Entry; + first: Record; + events: Event[]; + reviews: PolicyResult["candidates"]; + outcomes: PolicyResult["outcomes"]; + working: State; + selectedTarget: string | undefined; + facts?: DirectFacts; + candidateRun?: CandidateRun; + directQuestion?: boolean; + quotedOption?: ( + candidate: QuoteCandidate, + index: number, + threadId: string, + state: State, + ) => Event; +}; + +/** Initialize before running terminals; quote budget is checked before any other input reads. */ +export function createPolicyContext(input: PolicyInput): PolicyContext { + assertQuoteBudget(input.candidates); + let { channelId, message, first } = input; + let events: Event[] = []; + let reviews: PolicyResult["candidates"] = []; + let outcomes: PolicyResult["outcomes"] = []; + let working = input.state; + let selectedTarget: string | undefined = undefined; + + return { input, channelId, message, first, events, reviews, outcomes, working, selectedTarget }; +} + +/** Called only after the runner has rejected ineligible messages. */ +export function directFacts(context: PolicyContext): DirectFacts { + let { input, message } = context; + // A new question expressed as several options has no existing thread ID for pass 2 to target. + // Keep the question and each option tied to exact spans of this one bounded message. + let optionLabels = input.candidates.map(candidate => + candidate.quote.trim().replace(/\s+/g, " ").toLowerCase() + ); + let directAlternatives = directAlternativeQuotes(message.text); + let exactDirectAlternatives = directAlternatives.length === input.candidates.length + && directAlternatives.length >= 2 + && directAlternatives.every((quote, index) => + quote.start === input.candidates[index]?.start + && quote.end === input.candidates[index]?.end + && quote.quote === input.candidates[index]?.quote + ); + let questionKey = message.text.trim().replace(/\s+/g, " ").toLowerCase(); + let matchingThreads = input.state.threads.filter(thread => + thread.status !== "discarded" + && thread.question.trim().replace(/\s+/g, " ").toLowerCase() === questionKey + ); + let directBounds = message.text.length <= 500 + && new Set(optionLabels).size === input.candidates.length + && input.candidates.every(candidate => + candidate.quote.length <= 500 && !!candidate.quote.trim() + && message.text.slice(candidate.start, candidate.end) === candidate.quote + ); + let questionList = directAlternatives.length >= 3 + && message.text.indexOf("?") >= 0 + && message.text.indexOf("?") < directAlternatives[0]!.start; + + return { + optionLabels, + directAlternatives, + exactDirectAlternatives, + questionKey, + matchingThreads, + directBounds, + questionList, + }; +} diff --git a/apps/server/src/conversation-plan/policy-cues.ts b/apps/server/src/conversation-plan/policy-cues.ts new file mode 100644 index 00000000..42653cd8 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-cues.ts @@ -0,0 +1,45 @@ +export const OWNED_QUOTE_MIN = 0.7; +export const COMMA_ALTERNATIVES = + /^[^,;?]{2,80}(?:,\s*[^,;?]{2,80})+(?:,\s*or\s+|\s+or\s+)[^,;?]{2,80}\??$/i; +export function directResolution(text: string): boolean { + return /^\s*(?:and\s+)?(?:we(?: have|['’]ve)? decided|let['’]?s (?:just )?(?:make|do|use|choose|pick|go with)|we(?:'re| are) going with)\b/i + .test(text) + && !/\b(?:please (?:show|quote|display)|said|told|claimed|reported|according to|apparently|seems?|i guess|maybe|perhaps|i prefer)\b/i + .test(text) + && !/[🙄]/u.test(text); +} +export function directOwnedCommitment(text: string): boolean { + return directResolution(text) && !text.trim().endsWith("?") + && !/\b(?:not|don't|do not|without|if|unless)\b/i.test(text); +} +export function directRecommendation(text: string): boolean { + return /^\s*(?:and\s+)?(?:we\s+should|i\s+think\s+we\s+should)\s+(?:also\s+)?(?:do|use|choose|pick|go with|host|store|send)\b/i + .test(text) + && !/\b(?:not|don't|do not|without|maybe|perhaps|probably|i guess|if|unless|said|asked)\b/i + .test(text) + && !text.trim().endsWith("?"); +} +export function directReopening(text: string): boolean { + return /\b(?:reopen|reconsider|let's revisit|we should revisit)\b/i.test(text) + && !/\b(?:please (?:show|quote|display)|said|apparently)\b/i.test(text); +} + +export function namesCardOption(quote: string, label: string): boolean { + let escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(? 80) return false; + let content = + /^Start with (?:a|an|the) ([\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*(?: [\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*){1,5})[.!?]?$/iu + .exec(label)?.[1]; + return !!content + && ["a", "an", "the"].some(article => namesCardOption(quote, `${article} ${content}`)); +} + +export function immediateSpikeCondition(text: string, end: number): boolean { + return /^\s*(?:[,;:]\s*)?(?:if|unless|provided(?:\s+that)?)(?=\s|$)/iu + .test(text.slice(end, end + 160)); +} diff --git a/apps/server/src/conversation-plan/policy-declarative.test.ts b/apps/server/src/conversation-plan/policy-declarative.test.ts new file mode 100644 index 00000000..d947aea7 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-declarative.test.ts @@ -0,0 +1,223 @@ +import { expect, test } from "bun:test"; +import type { JevAnswer } from "./jev"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { stateWithOption, terminalPolicy as planEvents } from "./policy-terminal.test-fixtures"; +import { extractQuotes } from "./quotes"; + +// Preserved from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/pipeline.test.ts. +test("captures two sourced options from an owned declarative explanation of an open topic", () => { + let state = stateWithOption( + "How should we provide agent access?", + "access-thread", + "existing-access", + "Use a hosted gateway.", + ); + let current = message( + "access-alternatives", + "By agent access I mean Copilot or bring-your-own API keys.", + ); + let quotes = extractQuotes(current.text); + expect(quotes).toEqual([ + { quote: "Copilot", start: 23, end: 30 }, + { quote: "bring-your-own API keys", start: 34, end: 57 }, + ]); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ new_option: 0.93 }), + thread_target: confidentChoice("access-thread"), + }, + candidates: quotes.map(quote => ({ + ...quote, + answers: { + ...follow({ role: "none", thread: "access-thread" }), + new_option: { type: "noul", noul: 0.05 }, + duplicate: { type: "noul", noul: 0.04 }, + }, + })), + }); + expect(output.events.map(event => event.type)).toEqual(["option.added", "option.added"]); + expect(output.events.map(event => event.type === "option.added" && event.contribution.text)) + .toEqual(quotes.map(quote => quote.quote)); + expect(output.events.map(event => "source" in event && event.source)).toMatchObject( + quotes.map(quote => ({ ...quote, role: "option" })), + ); + expect(output.events.some(event => + event.type === "settle.suggested" + || event.type === "decision.recorded" + )).toBe(false); +}); +test("recovers a declarative pair when the whole-message target resolves one weaker span", () => { + let state = stateWithOption( + "How should we provide agent access?", + "access-thread", + "existing-access", + "Use a hosted gateway.", + ); + let current = message( + "observed-access-pair", + "By agent access I mean Copilot or bring-your-own API keys.", + ); + let quotes = extractQuotes(current.text); + let triage = { + ...first({ + new_option: 0.93, + c0_owned_unretracted: 0.79, + c1_owned_unretracted: 0.86, + }), + thread_target: { + type: "choice" as const, + choice: "access-thread", + confidence: 0.98, + probabilities: { "access-thread": 0.98, none: 0.02 }, + }, + }; + let candidates: Parameters[0]["candidates"] = quotes.map((quote, index) => { + let thread: JevAnswer = index + ? { + type: "choice" as const, + choice: "access-thread", + confidence: 0.96, + probabilities: { "access-thread": 0.98, none: 0.02 }, + } + : { + type: "choice" as const, + choice: "access-thread", + confidence: 0.45, + probabilities: { "access-thread": 0.59, none: 0.29, other: 0.09, new: 0.03 }, + }; + return { + ...quote, + answers: { + ...follow({ role: index ? "option" : "none", thread: "access-thread" }), + thread, + new_option: { type: "noul" as const, noul: index ? 0.86 : 0.4 }, + duplicate: { type: "noul" as const, noul: index ? 0.13 : 0.37 }, + }, + }; + }); + let assess = ( + firstAnswers = triage as Record, + candidateAnswers = candidates, + ) => + planEvents({ + channelId: "channel", + message: current, + state, + first: firstAnswers, + candidates: candidateAnswers, + }); + expect(assess().events.map(event => event.type)).toEqual(["option.added", "option.added"]); + expect(assess().events.map(event => "source" in event && event.source)).toMatchObject( + quotes.map(quote => ({ ...quote, role: "option" })), + ); + expect( + assess({ + ...triage, + thread_target: { + ...triage.thread_target, + probabilities: { "access-thread": 0.84, none: 0.16 }, + }, + }).events, + ).toEqual([]); + expect( + assess(triage, [{ + ...candidates[0]!, + answers: { + ...candidates[0]!.answers, + thread: { + type: "choice", + choice: "access-thread", + confidence: 0.35, + probabilities: { "access-thread": 0.52, none: 0.4, other: 0.08 }, + }, + }, + }, candidates[1]!]).events, + ).toEqual([]); + expect( + assess(triage, [candidates[0]!, { + ...candidates[1]!, + answers: { ...candidates[1]!.answers, thread: candidates[0]!.answers.thread }, + }]).events, + ).toEqual([]); + expect( + assess(triage, [{ + ...candidates[0]!, + answers: { + ...candidates[0]!.answers, + duplicate: { type: "noul", noul: 0.72 }, + }, + }, candidates[1]!]).events, + ).toEqual([]); +}); +test("a definition of CI failure causes needs review without option evidence", () => { + let state = stateWithOption( + "How should we improve CI reliability?", + "quality-thread", + "existing-quality", + "Retry failed jobs automatically.", + ); + let current = message( + "observed-definition", + "By CI reliability I mean flaky tests or missing dependencies.", + ); + let quotes = extractQuotes(current.text); + expect(quotes).toEqual([ + { quote: "flaky tests", start: 25, end: 36 }, + { quote: "missing dependencies", start: 40, end: 60 }, + ]); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ c0_owned_unretracted: 0.94, c1_owned_unretracted: 0.95 }), + new_option: { type: "noul", noul: 0.59 }, + thread_target: { + type: "choice", + choice: "quality-thread", + confidence: 0.84, + probabilities: { "quality-thread": 0.84, none: 0.16 }, + }, + significance: { + type: "score", + score: 1.47, + confidence: 0.5, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "changes work" }, + probabilities: { "0": 0.2, "1": 0.2, "2": 0.53, "3": 0.07 }, + }, + }, + candidates: quotes.map((quote, index) => ({ + ...quote, + answers: { + ...follow({ role: index ? "none" : "reason", thread: "quality-thread" }), + role: index + ? { + type: "choice" as const, + choice: "none", + confidence: 0.38, + probabilities: { none: 0.38, reason: 0.35, option: 0.27 }, + } + : { + type: "choice" as const, + choice: "reason", + confidence: 0.45, + probabilities: { reason: 0.45, none: 0.39, option: 0.16 }, + }, + thread: { + type: "choice" as const, + choice: "quality-thread", + confidence: index ? 0.84 : 0.99, + probabilities: { "quality-thread": index ? 0.84 : 0.99, none: index ? 0.16 : 0.01 }, + }, + new_option: { type: "noul" as const, noul: index ? 0.11 : 0.16 }, + duplicate: { type: "noul" as const, noul: index ? 0.18 : 0.23 }, + }, + })), + }); + expect(output.events).toEqual([]); + expect(output.policyGate).toBe("declarative options need review"); +}); diff --git a/apps/server/src/conversation-plan/policy-declarative.ts b/apps/server/src/conversation-plan/policy-declarative.ts new file mode 100644 index 00000000..68bae41c --- /dev/null +++ b/apps/server/src/conversation-plan/policy-declarative.ts @@ -0,0 +1,108 @@ +import { applyInference } from "./domain"; +import { OWNED_QUOTE_MIN } from "./policy-cues"; +import type { PolicyContext } from "./policy-context"; +import { choice, noul } from "./policy-scoring"; +import type { Event, PolicyResult } from "./policy-types"; +import { declarativeAlternativeQuotes } from "./quotes"; + +export function declarativePair(context: PolicyContext): PolicyResult | undefined { + let { input, message, first, events, reviews, outcomes } = context; + let { optionLabels } = context.facts!; + let quotedOption = context.quotedOption!; + let declarative = declarativeAlternativeQuotes(message.text); + if (declarative.length === 2) { + let target = choice(first, "thread_target"); + let thread = input.state.threads.find(item => item.id === target); + let wholeTarget = first.thread_target; + let certainCandidates = input.candidates.map(candidate => + choice(candidate.answers, "thread") === target + ); + let corroboratedTarget = wholeTarget?.type === "choice" + && (wholeTarget.probabilities[target ?? ""] ?? 0) >= 0.95 + && certainCandidates.some(Boolean); + let candidateTargets = input.candidates.map((candidate, index) => { + if (certainCandidates[index]) return true; + let answer = candidate.answers.thread; + if (!corroboratedTarget || answer?.type !== "choice" || answer.choice !== target) { + return false; + } + let probability = answer.probabilities[target ?? ""] ?? 0; + let alternative = Math.max( + 0, + ...Object.entries(answer.probabilities) + .filter(([key]) => key !== target).map(([, value]) => value), + ); + return probability >= 0.55 && probability - alternative >= 0.2; + }); + let existing = new Set( + thread?.contributions.filter(item => item.kind === "option") + .map(item => item.text.trim().replace(/[.!?]+$/, "").replace(/\s+/g, " ").toLowerCase()), + ); + let optionEvidence = noul(first, "new_option") >= 0.8 + || input.candidates.some(candidate => + noul(candidate.answers, "new_option") >= 0.75 + || candidate.answers.role?.type === "choice" + && candidate.answers.role.choice === "option" + && (candidate.answers.role.probabilities.option ?? 0) >= 0.75 + ); + let eligible = message.author.kind === "member" && !!thread + && !["decided", "discarded"].includes(thread.status) + && optionEvidence + && declarative.every((quote, index) => + quote.quote === input.candidates[index]?.quote + && quote.start === input.candidates[index]?.start + && quote.end === input.candidates[index]?.end + && noul(first, `c${index}_owned_unretracted`) >= OWNED_QUOTE_MIN + && candidateTargets[index] + && noul(input.candidates[index]!.answers, "duplicate") + < (certainCandidates[index] ? 0.3 : 0.5) + && !existing.has(optionLabels[index]) + ) + && input.candidates.length === 2 + && new Set(optionLabels).size === 2; + if (!eligible || !thread) { + return { + events, + candidates: reviews, + outcomes: input.candidates.map(candidate => ({ + start: candidate.start, + end: candidate.end, + status: "review", + gate: "declarative options need review", + eventIds: [], + })), + policyGate: "declarative options need review", + }; + } + let staged = input.state; + let additions: Event[] = []; + for (let [index, candidate] of input.candidates.entries()) { + let added = quotedOption(candidate, index, thread.id, staged); + try { + staged = applyInference(staged, added, message); + } catch { + return { + events, + candidates: reviews, + outcomes, + policyGate: "declarative options rejected", + }; + } + additions.push(added); + } + return { + events: additions, + selectedTarget: thread.id, + candidates: reviews, + outcomes: input.candidates.map((candidate, index) => ({ + start: candidate.start, + end: candidate.end, + status: "accepted", + gate: "accepted", + eventIds: [additions[index]!.id], + targetId: thread.id, + })), + policyGate: "declarative options accepted", + }; + } +} diff --git a/apps/server/src/conversation-plan/policy-direct-alternatives.ts b/apps/server/src/conversation-plan/policy-direct-alternatives.ts new file mode 100644 index 00000000..21787d1a --- /dev/null +++ b/apps/server/src/conversation-plan/policy-direct-alternatives.ts @@ -0,0 +1,83 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import type { PolicyContext } from "./policy-context"; +import { stableId } from "./policy-identity"; +import type { Event, PolicyResult } from "./policy-types"; + +export function directAlternatives( + context: PolicyContext, + directQuestion: boolean, +): PolicyResult | undefined { + let { input, channelId, message, events, reviews, outcomes } = context; + let { directBounds } = context.facts!; + let quotedOption = context.quotedOption!; + let working = context.working; + // Retain prior successful inference even when a later option rejects or throws. + try { + if (directQuestion && directBounds) { + let threadId = `thread:${stableId(channelId, message.id, 0, "thread").slice(11)}`; + let opening: Event = { + id: stableId(channelId, message.id, 0, "thread.opened"), + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: message.author.kind === "agent" ? "planner" : "classifier", + actor: message.author.kind === "agent" ? { kind: "agent" } : { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "question", + }, + question: message.text, + }; + try { + working = applyInference(working, opening, message); + } catch { + return { events, candidates: reviews, outcomes, policyGate: "direct question rejected" }; + } + let directEvents: Event[] = [opening]; + let directOutcomes: PolicyResult["outcomes"] = []; + for (let [index, candidate] of input.candidates.entries()) { + let added = quotedOption(candidate, index, threadId, working); + try { + working = applyInference(working, added, message); + } catch { + return { + events: [], + candidates: reviews, + outcomes: input.candidates.map(item => ({ + start: item.start, + end: item.end, + status: "review", + gate: "direct option rejected", + eventIds: [], + })), + policyGate: "direct option rejected", + }; + } + directEvents.push(added); + directOutcomes.push({ + start: candidate.start, + end: candidate.end, + status: "accepted", + gate: "accepted", + eventIds: index === 0 ? [opening.id, added.id] : [added.id], + targetId: threadId, + }); + } + return { + events: directEvents, + selectedTarget: threadId, + candidates: reviews, + outcomes: directOutcomes, + policyGate: "direct alternatives accepted", + }; + } + } finally { + context.working = working; + } +} diff --git a/apps/server/src/conversation-plan/policy-direct-recovery.test.ts b/apps/server/src/conversation-plan/policy-direct-recovery.test.ts new file mode 100644 index 00000000..c747ec4c --- /dev/null +++ b/apps/server/src/conversation-plan/policy-direct-recovery.test.ts @@ -0,0 +1,144 @@ +import { expect, test } from "bun:test"; +import { applyInference } from "./domain"; +import { applyEvent } from "./events"; +import { + addMatchingThread, + confidentChoice, + inputFor, + prefix, + seedOptions, + terminalPolicy, +} from "./policy-initial.test-fixtures"; +import { MAX_EVENTS } from "./validation"; + +let question = "Should audit logs go in PostgreSQL or object storage?"; + +function recovery() { + let input = inputFor(question); + input.state = addMatchingThread(input, "audit"); + return input; +} + +test.each(["audit", "new"])("an exact question recovers missing options with target %s", target => { + let input = recovery(); + input.first.thread_target = confidentChoice(target); + let before = structuredClone(input); + let result = terminalPolicy(input); + expect(result.policyGate).toBe("direct options recovered"); + expect(result.events.map(event => event.type)).toEqual(["option.added", "option.added"]); + expect(result.events.map(event => event.observedThreadVersion)).toEqual([1, 2]); + expect(result.events.map(event => "source" in event ? event.source?.quote : undefined)) + .toEqual(input.candidates.map(candidate => candidate.quote)); + expect(result.selectedTarget).toBe("audit"); + expect(input).toEqual(before); +}); + +test("partial recovery ignores existing wording and adds only its missing option", () => { + let input = recovery(); + seedOptions(input, 1); + let before = structuredClone(input); + let result = terminalPolicy(input); + expect(result.events).toHaveLength(1); + expect(result.outcomes.map(outcome => outcome.gate)).toEqual([ + "option already exists", + "accepted", + ]); + expect(result.events[0]!.observedThreadVersion).toBe(2); + expect(input).toEqual(before); +}); + +test("a complete repeated question returns a terminal result without new events", () => { + let input = recovery(); + seedOptions(input, 2); + let result = terminalPolicy(input); + expect(result.policyGate).toBe("question already complete"); + expect(result.events).toEqual([]); + expect(result.outcomes.every(outcome => outcome.gate === "option already exists")).toBe(true); +}); + +test("ambiguous exact matches and unclear targets are terminal without recovery", () => { + let input = recovery(); + input.state = addMatchingThread(input, "other-audit"); + expect(terminalPolicy(input).policyGate).toBe("question recovery unclear"); + input = recovery(); + input.first.thread_target = confidentChoice("none"); + let result = terminalPolicy(input); + expect(result.policyGate).toBe("question recovery unclear"); + expect(result.events).toEqual([]); + expect(result.outcomes.every(outcome => outcome.status === "ignored")).toBe(true); +}); + +test("discarded exact questions fall through rather than being recovered", () => { + let input = recovery(); + input.state = applyEvent(input.state, { + id: "discard-audit", + type: "thread.discarded", + threadId: "audit", + observedThreadVersion: 1, + origin: "human", + actor: { kind: "member", handle: "Mina" }, + at: 1001, + }); + let before = structuredClone(input); + expect(prefix(input)).toBeUndefined(); + expect(input).toEqual(before); +}); + +test("agent recovery preserves planner attribution and stable option identities", () => { + let input = recovery(); + input.message.author = { kind: "agent" }; + let result = terminalPolicy(input); + expect(result.events.map(event => event.origin)).toEqual(["planner", "planner"]); + expect(result.events.map(event => event.actor)).toEqual([{ kind: "agent" }, { kind: "agent" }]); + expect(terminalPolicy(input)).toEqual(result); +}); + +test("event cap rejection removes every staged recovery event", () => { + let input = recovery(); + let opening = input.state.events[0]!; + input.state.events = Array.from({ length: MAX_EVENTS - 1 }, (_, index) => ({ + ...opening, + id: `historical-${index}`, + })); + let before = structuredClone(input); + let result = terminalPolicy(input); + expect(result.policyGate).toBe("direct option recovery rejected"); + expect(result.events).toEqual([]); + expect(result.outcomes.map(outcome => outcome.status)).toEqual(["review", "review"]); + expect(input).toEqual(before); +}); + +test("contribution cap rejection removes an earlier staged addition", () => { + let input = recovery(); + let candidate = input.candidates[0]!; + for (let index = 0; index < 63; index++) { + let thread = input.state.threads[0]!; + input.state = applyInference(input.state, { + id: `reason-${index}`, + type: "reason.added", + threadId: thread.id, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: input.message.ts, + source: { + messageId: input.message.id, + author: { kind: "member", handle: "Mina" }, + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + role: "reason", + }, + contribution: { + id: `reason-contribution-${index}`, + text: candidate.quote, + authoring: "quoted", + }, + }, input.message); + } + let before = structuredClone(input); + let result = terminalPolicy(input); + expect(result.policyGate).toBe("direct option recovery rejected"); + expect(result.events).toEqual([]); + expect(input).toEqual(before); +}); diff --git a/apps/server/src/conversation-plan/policy-direct-recovery.ts b/apps/server/src/conversation-plan/policy-direct-recovery.ts new file mode 100644 index 00000000..05cede12 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-direct-recovery.ts @@ -0,0 +1,125 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import type { DirectFacts, PolicyContext } from "./policy-context"; +import { optionIdFor, stableId } from "./policy-identity"; +import { choice } from "./policy-scoring"; +import type { Event, PolicyResult, State } from "./policy-types"; +import type { QuoteCandidate } from "./quotes"; + +export function createQuotedOption(context: PolicyContext) { + let { channelId, message } = context; + let quotedOption = ( + candidate: QuoteCandidate, + index: number, + threadId: string, + state: State, + ): Event => ({ + id: stableId(channelId, message.id, index, "option.added"), + type: "option.added", + threadId, + observedThreadVersion: state.threads.find(item => item.id === threadId)?.version ?? 0, + origin: message.author.kind === "agent" ? "planner" : "classifier", + actor: message.author.kind === "agent" ? { kind: "agent" } : { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + role: "option", + }, + contribution: { + id: optionIdFor(channelId, message.id, index, message.ts), + text: candidate.quote, + authoring: "quoted", + targetId: threadId, + }, + }); + + return quotedOption; +} + +export function directRecovery( + context: PolicyContext, + facts: DirectFacts, + quotedOption: ReturnType, +): PolicyResult | undefined { + let { input, message, first, events, reviews } = context; + let { exactDirectAlternatives, matchingThreads, directBounds, optionLabels } = facts; + if (exactDirectAlternatives && matchingThreads.length) { + let thread = matchingThreads.length === 1 ? matchingThreads[0] : undefined; + let existing = new Set( + thread?.contributions.filter(item => item.kind === "option") + .map(item => item.text.trim().replace(/\s+/g, " ").toLowerCase()), + ); + let staged = input.state; + let additions: Event[] = []; + let recoveryOutcomes: PolicyResult["outcomes"] = []; + let target = choice(first, "thread_target"); + if ( + thread && directBounds && (target === thread.id || target === "new") + && choice(first, "act") === "question" + ) { + for (let [index, candidate] of input.candidates.entries()) { + let label = optionLabels[index]!; + if (existing.has(label)) { + recoveryOutcomes.push({ + start: candidate.start, + end: candidate.end, + status: "ignored", + gate: "option already exists", + eventIds: [], + }); + continue; + } + let added = quotedOption(candidate, index, thread.id, staged); + try { + staged = applyInference(staged, added, message); + } catch { + return { + events: [], + candidates: reviews, + outcomes: input.candidates.map(item => ({ + start: item.start, + end: item.end, + status: "review", + gate: "direct option recovery rejected", + eventIds: [], + })), + policyGate: "direct option recovery rejected", + }; + } + existing.add(label); + additions.push(added); + recoveryOutcomes.push({ + start: candidate.start, + end: candidate.end, + status: "accepted", + gate: "accepted", + eventIds: [added.id], + targetId: thread.id, + }); + } + return { + events: additions, + selectedTarget: thread.id, + candidates: reviews, + outcomes: recoveryOutcomes, + policyGate: additions.length ? "direct options recovered" : "question already complete", + }; + } + return { + events, + candidates: reviews, + outcomes: input.candidates.map(candidate => ({ + start: candidate.start, + end: candidate.end, + status: "ignored", + gate: "question recovery unclear", + eventIds: [], + })), + policyGate: "question recovery unclear", + }; + } +} diff --git a/apps/server/src/conversation-plan/policy-dispatcher.test-fixtures.ts b/apps/server/src/conversation-plan/policy-dispatcher.test-fixtures.ts new file mode 100644 index 00000000..9b36d0e5 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-dispatcher.test-fixtures.ts @@ -0,0 +1,52 @@ +import { createPolicyContext } from "./policy-context"; +import { addMatchingThread, follow, inputFor } from "./policy-initial.test-fixtures"; +import { supportInput } from "./policy-candidate-application.test-fixtures"; +import { directAlternativeQuotes, extractQuotes } from "./quotes"; +import type { PolicyInput } from "./policy-types"; + +// Offline completion fixtures for archive446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// policy.ts1729–1769 and original candidate loop control. Original callbacks remain assigned whole. +export function fallbackInput(): PolicyInput { + let input = inputFor("Should we use Alpha or Alpha?"); + input.candidates = directAlternativeQuotes(input.message.text).map(quote => ({ + ...quote, + answers: follow({ role: "question", thread: "new" }), + })); + input.first.c0_owned_unretracted = { type: "noul", noul: 0.69 }; + return input; +} +export function finalContext(input = fallbackInput()) { + let context = createPolicyContext(input); + context.directQuestion = true; + context.outcomes = input.candidates.map(candidate => ({ + start: candidate.start, + end: candidate.end, + status: "ignored", + gate: "no useful role", + eventIds: [], + })); + return context; +} + +export function multipleSupportInput(): PolicyInput { + let input = supportInput(["Jules"]); + let answers = input.candidates[0]!.answers; + input.message.text = Array.from( + { length: 4 }, + (_, index) => `I support Alpha for reason ${index}.`, + ).join(" "); + input.candidates = extractQuotes(input.message.text).map(quote => ({ + ...quote, + answers: structuredClone(answers), + })); + for (let index = 0; index < 4; index++) { + input.first[`c${index}_owned_unretracted`] = { type: "noul", noul: 0.95 }; + } + return input; +} + +export function recoveryInput(): PolicyInput { + let input = inputFor("Should audit logs go in PostgreSQL or object storage?"); + input.state = addMatchingThread(input, "audit"); + return input; +} diff --git a/apps/server/src/conversation-plan/policy-dispatcher.test.ts b/apps/server/src/conversation-plan/policy-dispatcher.test.ts new file mode 100644 index 00000000..e8a06fa1 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-dispatcher.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test"; +import { planEvents } from "./policy"; +import { + fallbackInput, + multipleSupportInput, + recoveryInput, +} from "./policy-dispatcher.test-fixtures"; +import { supportInput } from "./policy-candidate-application.test-fixtures"; +import { contributionInput } from "./policy-candidate-contribution.test-fixtures"; +import { ordinaryInput } from "./policy-candidate-ordinary.test-fixtures"; +import { reopeningInput, resolutionInput } from "./policy-candidate-resolution.test-fixtures"; +import { newChoiceInput, scopedInput } from "./policy-candidate-scoped.test-fixtures"; +import { stanceInput } from "./policy-candidate-stance.test-fixtures"; +import { confidentChoice, inputFor } from "./policy-initial.test-fixtures"; +import { replay } from "./domain"; +import { d01RecordedOpening, declarativeInput } from "./policy-terminal.test-fixtures"; +import { verificationInput } from "./policy-candidate-verification.test-fixtures"; + +// Complete pure dispatcher regressions; no runtime callers or original callback replacements. +test.each( + [ + ["reason", () => contributionInput(), "reason.added"], + ["option", () => contributionInput("option"), "option.added"], + ["constraint", () => contributionInput("constraint"), "constraint.added"], + ["support", () => supportInput([], true), "stance.changed"], + ["objection", () => stanceInput("objection"), "stance.changed"], + ["withdrawal", () => ordinaryInput(), "stance.changed"], + ["none", () => ordinaryInput("none"), "settle.agreed"], + ["question", () => ordinaryInput("question"), "thread.opened"], + ["resolution", () => resolutionInput(), "settle.suggested"], + ["reopening", () => reopeningInput(), "candidate.proposed"], + ] as const, +)("complete matching-role route %s applies %s", (_role, make, type) => { + let input = make(), before = structuredClone(input); + let result = planEvents(input); + expect(result.events[0]!.type).toBe(type); + expect(result.outcomes[0]!.status).toBe(type === "candidate.proposed" ? "review" : "accepted"); + expect(input).toEqual(before); + expect(() => replay([...input.state.events, ...result.events])).not.toThrow(); +}); +test.each([[false, "scoped-choice.proposed"], [true, "scoped-choice.agreed"]] as const)( + "scoped path %s handles before ordinary application", + (assent, type) => { + let input = scopedInput(assent), result = planEvents(input); + expect(result.events.map(item => item.type)).toEqual([type]); + expect(result.outcomes[0]!.status).toBe("accepted"); + }, +); +test("direct new choice handles before ordinary role proposals", () => { + let result = planEvents(newChoiceInput()); + expect(result.events.map(item => item.type)).toEqual(["option.added", "settle.suggested"]); +}); +test("fallback opens from the later owned candidate without changing index-zero IDs", () => { + let result = planEvents(fallbackInput()); + expect(result.events.map(item => item.type)).toEqual(["thread.opened"]); + expect(result.outcomes[0]!.status).toBe("ignored"); + expect(result.outcomes[1]!.status).toBe("accepted"); + expect(Object.hasOwn(result, "selectedTarget")).toBe(true); +}); +test("truthy unrecognized role produces no proposal without throwing", () => { + let input = contributionInput(); + input.candidates[0]!.answers.role = confidentChoice("toString"); + let result = planEvents(input); + expect(result.events).toEqual([]); + expect(result.outcomes).toHaveLength(1); +}); +test("ineligible terminal result omits the ordinary selectedTarget property", () => { + let input = inputFor("Alpha or Beta?"); + input.message.streaming = true; + let result = planEvents(input); + expect(result.policyGate).toBe("ineligible message"); + expect(Object.hasOwn(result, "selectedTarget")).toBe(false); +}); +test("quote budget still precedes eligibility", () => { + let input = inputFor("Hi"); + input.message.streaming = true; + input.candidates = Array.from({ length: 13 }, () => input.candidates[0]!); + expect(() => planEvents(input)).toThrow(); +}); +test("candidate source skip preserves later processing and captured candidate order", () => { + let input = contributionInput(); + input.candidates = [{ ...input.candidates[0]!, quote: "wrong source" }, input.candidates[0]!]; + input.first.c1_owned_unretracted = { type: "noul", noul: 0.95 }; + let result = planEvents(input); + expect(result.outcomes).toHaveLength(2); + expect(result.outcomes[0]!.eventIds).toEqual([]); + expect(result.outcomes[1]!.status).toBe("accepted"); + expect(result.events.map(item => item.type)).toEqual(["reason.added"]); +}); + +test("multiple support candidates preserve order and share the first leaning transition", () => { + let input = multipleSupportInput(); + expect(input.candidates).toHaveLength(4); + let result = planEvents(input); + expect(result.events).toHaveLength(5); + expect(result.outcomes).toHaveLength(4); + expect(result.events[1]!.type).toBe("thread.leaning"); + expect(result.outcomes.every(item => item.status === "accepted")).toBe(true); + expect(() => replay([...input.state.events, ...result.events])).not.toThrow(); +}); + +test.each( + [ + [ + "owned compound", + () => inputFor("We need to choose how people sign in and how agents get credentials."), + "explicit compound questions accepted", + ], + [ + "attributed compound", + () => + inputFor("Alice said we need to choose how people sign in and how agents get credentials."), + "compound decision attribution unclear", + ], + ["recovery", recoveryInput, "direct options recovered"], + [ + "direct alternatives", + () => inputFor("Should audit logs go in PostgreSQL or object storage?"), + "direct alternatives accepted", + ], + ["declarative pair", declarativeInput, "declarative options accepted"], + ["purpose and question", d01RecordedOpening, "owned purpose and final question accepted"], + ] as const, +)("complete terminal path %s retains %s", (_name, make, gate) => { + let input = make(), before = structuredClone(input), result = planEvents(input); + expect(result.policyGate).toBe(gate); + expect(input).toEqual(before); + expect(() => replay([...input.state.events, ...result.events])).not.toThrow(); +}); + +test("verified deferred proposal resumes before later scoped and ordinary handlers", () => { + let input = verificationInput(), before = structuredClone(input), result = planEvents(input); + expect(result.events.map(item => item.type)).toEqual(["settle.resumed"]); + expect(result.outcomes[0]!.status).toBe("accepted"); + expect(input).toEqual(before); + expect(() => replay([...input.state.events, ...result.events])).not.toThrow(); +}); diff --git a/apps/server/src/conversation-plan/policy-explicit-list.test.ts b/apps/server/src/conversation-plan/policy-explicit-list.test.ts new file mode 100644 index 00000000..a95badcd --- /dev/null +++ b/apps/server/src/conversation-plan/policy-explicit-list.test.ts @@ -0,0 +1,256 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import type { PolicyInput } from "./policy-types"; +import { d01RecordedOpening } from "./policy-terminal.test-fixtures"; +import { planEvents } from "./policy"; +import { buildCandidateTargetingRequest, buildTriageRequest } from "./questions"; +import { extractQuotes } from "./quotes"; +import { MAX_EVENTS } from "./validation"; + +// Preserved from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/pipeline.test.ts. +test("D01's bare editor list adds four exact options to its uniquely open thread", () => { + let opening = d01RecordedOpening(); + let opened = planEvents(opening).events[0]!; + let state = applyInference(opening.state, opened, opening.message); + let threadId = state.threads[0]!.id; + let current = message( + "d01-m2", + "Tiptap, bare ProseMirror, Lexical, or just native Selection and Range with our own document model.", + "Nia", + ); + let quotes = extractQuotes(current.text); + expect(quotes).toEqual([ + { quote: "Tiptap", start: 0, end: 6 }, + { quote: "bare ProseMirror", start: 8, end: 24 }, + { quote: "Lexical", start: 26, end: 33 }, + { + quote: "just native Selection and Range with our own document model", + start: 38, + end: 97, + }, + ]); + let triage = buildTriageRequest(current, [], state.threads, quotes); + expect(triage.questions.c3_owned_unretracted?.type).toBe("noul"); + let fourth = buildCandidateTargetingRequest(current, [], state.threads, quotes, 3); + expect(fourth.questions.c3_role?.type).toBe("choice"); + expect((fourth.state as { current: { text: string } }).current.text).toBe(quotes[3]!.quote); + let output = planEvents({ + channelId: "channel", + message: current, + state, + first: { + ...first({ + new_option: 0.95, + c3_owned_unretracted: 0.95, + }), + act: confidentChoice("proposal"), + thread_target: confidentChoice(threadId), + }, + candidates: quotes.map(quote => ({ + ...quote, + answers: { + ...follow({ role: "option", thread: threadId }), + option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.95 }, + duplicate: { type: "noul", noul: 0.05 }, + }, + })), + }); + expect(output.events.map(event => event.type)).toEqual(Array(4).fill("option.added")); + expect(output.events.map(event => event.type === "option.added" && event.threadId)) + .toEqual(Array(4).fill(threadId)); + expect(output.events.map(event => event.type === "option.added" && event.contribution.text)) + .toEqual(quotes.map(quote => quote.quote)); + expect(output.events.map(event => "source" in event && event.source)).toMatchObject( + quotes.map(quote => ({ ...quote, messageId: current.id, role: "option" })), + ); + expect( + new Set(output.events.map(event => event.type === "option.added" && event.contribution.id)) + .size, + ).toBe(4); +}); +test("D01's bare list cannot create options without one identifiable open thread", () => { + let opening = d01RecordedOpening(); + let editor = applyInference(opening.state, planEvents(opening).events[0]!, opening.message); + let another = message("other-question", "Which database should we use?", "Nia"); + let ambiguous = applyInference(editor, { + id: "other-thread-event", + type: "thread.opened", + threadId: "other-thread", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: another.ts, + source: { + messageId: another.id, + author: another.author as ConversationPlan.SourceAuthor, + quote: another.text, + start: 0, + end: another.text.length, + role: "question", + }, + question: another.text, + }, another); + let current = message( + "d01-m2-untargeted", + "Tiptap, bare ProseMirror, Lexical, or just native Selection and Range with our own document model.", + "Nia", + ); + let spans = [[0, 6], [8, 24], [26, 33], [38, 97]]; + let candidates: PolicyInput["candidates"] = spans.map(([start, end]) => ({ + quote: current.text.slice(start!, end!), + start: start!, + end: end!, + answers: { + ...follow({ role: "option", thread: "none", threadProbability: 0.55 }), + new_option: { type: "noul", noul: 0.95 }, + duplicate: { type: "noul", noul: 0.05 }, + }, + })); + let firstPass = { + ...first({ new_option: 0.95, c3_owned_unretracted: 0.95 }), + act: confidentChoice("proposal"), + thread_target: { + type: "choice" as const, + choice: "none", + confidence: 0.5, + probabilities: { none: 0.5, [editor.threads[0]!.id]: 0.25, "other-thread": 0.25 }, + }, + }; + for (let state of [initialState(), ambiguous]) { + expect( + planEvents({ + channelId: "channel", + message: current, + state, + first: firstPass, + candidates, + }).events, + ).toEqual([]); + } +}); + +test("an unrelated four-option list requires every option to be well supported", () => { + let opening = d01RecordedOpening(); + let state = applyInference(opening.state, planEvents(opening).events[0]!, opening.message); + let threadId = state.threads[0]!.id; + state.threads[0]!.question = "Which launch queue should we use?"; + let current = message( + "queue-options", + "Redis, SQS, Postgres, or a small in-process queue with disk replay.", + "Nia", + ); + let quotes = extractQuotes(current.text); + let input: PolicyInput = { + channelId: "channel", + message: current, + state, + first: { + ...first({ new_option: 0.95, c3_owned_unretracted: 0.95 }), + act: confidentChoice("proposal"), + thread_target: confidentChoice(threadId), + }, + candidates: quotes.map(quote => ({ + ...quote, + answers: { + ...follow({ role: "option", thread: threadId }), + option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.95 }, + duplicate: { type: "noul", noul: 0.05 }, + }, + })), + }; + expect(planEvents(input).events.map(event => event.type)).toEqual(Array(4).fill("option.added")); + let three = message("queue-options-three", "Redis, SQS, or Postgres.", "Nia"); + let threeQuotes = extractQuotes(three.text); + expect(threeQuotes).toHaveLength(3); + expect( + planEvents({ + ...input, + message: three, + candidates: threeQuotes.map((quote, index) => ({ + ...quote, + answers: input.candidates[index]!.answers, + })), + }).events.map(event => event.type), + ).toEqual(Array(3).fill("option.added")); + for ( + let [source, candidates] of [ + [current, input.candidates.slice(0, 2)], + [three, input.candidates.slice(0, 1)], + ] as const + ) { + let output = planEvents({ ...input, message: source, candidates }); + expect(output.events).toEqual([]); + expect(output.outcomes).toHaveLength(candidates.length); + expect(output.outcomes.every(outcome => outcome.status === "review")).toBe(true); + } + let capped = structuredClone(input); + let openingEvent = capped.state.events[0]!; + capped.state.events = Array.from({ length: MAX_EVENTS - 1 }, (_, index) => ({ + ...openingEvent, + id: `historical-${index}`, + })); + expect(planEvents(capped).events).toEqual([]); + input.candidates[1]!.answers.duplicate = { type: "noul", noul: 0.9 }; + expect(planEvents(input).events).toEqual([]); +}); + +test("an explicit list near the contribution limit does not add only its first option", () => { + let opening = d01RecordedOpening(); + let state = applyInference(opening.state, planEvents(opening).events[0]!, opening.message); + let thread = state.threads[0]!; + let seed = message("prior-options", "An earlier alternative.", "Nia"); + for (let index = 0; index < 63; index++) { + state = applyInference(state, { + id: `prior-option-event-${index}`, + type: "option.added", + threadId: thread.id, + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: seed.ts, + source: { + messageId: seed.id, + author: seed.author as ConversationPlan.SourceAuthor, + quote: seed.text, + start: 0, + end: seed.text.length, + role: "option", + }, + contribution: { + id: `prior-contribution-${index}`, + text: seed.text, + authoring: "quoted", + }, + }, seed); + } + let current = message("capacity-list", "Redis, SQS, Postgres, or SQLite.", "Nia"); + let quotes = extractQuotes(current.text); + let input: PolicyInput = { + channelId: "channel", + message: current, + state, + first: { + ...first({ new_option: 0.95, c3_owned_unretracted: 0.95 }), + act: confidentChoice("proposal"), + thread_target: confidentChoice(thread.id), + }, + candidates: quotes.map(quote => ({ + ...quote, + answers: { + ...follow({ role: "option", thread: thread.id }), + option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.95 }, + duplicate: { type: "noul", noul: 0.05 }, + }, + })), + }; + let output = planEvents(input); + expect(output.events).toEqual([]); + expect(output.outcomes).toHaveLength(4); + expect(output.outcomes.every(outcome => outcome.status === "review")).toBe(true); +}); diff --git a/apps/server/src/conversation-plan/policy-final.test.ts b/apps/server/src/conversation-plan/policy-final.test.ts new file mode 100644 index 00000000..08c07e3e --- /dev/null +++ b/apps/server/src/conversation-plan/policy-final.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { finishPolicy } from "./policy-final"; +import { finalContext } from "./policy-dispatcher.test-fixtures"; +import { stableId } from "./policy-identity"; +import { inputFor } from "./policy-initial.test-fixtures"; + +test("final fallback owns candidate one but uses index-zero identity and the full message", () => { + let context = finalContext(), before = context.working; + let result = finishPolicy(context); + expect(result.events).toHaveLength(1); + expect(result.events[0]).toMatchObject({ + id: stableId(context.channelId, context.message.id, 0, "thread.opened"), + question: context.message.text, + source: { quote: context.message.text, start: 0, end: context.message.text.length }, + }); + expect(result.outcomes[0]!.eventIds).toEqual([]); + expect(result.outcomes[1]!.eventIds).toEqual([result.events[0]!.id]); + expect(result.outcomes[1]!.status).toBe("accepted"); + expect(context.working).toBe(before); + expect(context.working.threads).toHaveLength(0); + expect(Object.hasOwn(result, "selectedTarget")).toBe(true); + expect(result.selectedTarget).toBeUndefined(); +}); +test("fallback reads live owned scores and role answers", () => { + let context = finalContext(); + context.first.c1_owned_unretracted = { type: "noul", noul: 0.69 }; + expect(finishPolicy(context).events).toEqual([]); + context.first.c1_owned_unretracted = { type: "noul", noul: 0.7 }; + context.input.candidates[1]!.answers.role = { + type: "choice", + choice: "none", + confidence: 1, + probabilities: { none: 1 }, + }; + expect(finishPolicy(context).events).toEqual([]); +}); +test("final gate truncates at exactly three hundred characters", () => { + let context = finalContext(); + context.directQuestion = false; + context.outcomes[0]!.gate = "x".repeat(299); + context.outcomes[1]!.gate = "later"; + let result = finishPolicy(context); + expect(result.policyGate).toBe(("0:" + "x".repeat(299) + "; 1:later").slice(0, 300)); + expect(result.policyGate).toHaveLength(300); +}); +test("final empty result preserves selectedTarget property presence", () => { + let input = inputFor("No source candidates."); + input.candidates = []; + let context = finalContext(input); + context.directQuestion = false; + let result = finishPolicy(context); + expect(result).toEqual({ + events: [], + selectedTarget: undefined, + candidates: [], + outcomes: [], + policyGate: "no source candidates", + }); + expect(Object.hasOwn(result, "selectedTarget")).toBe(true); +}); diff --git a/apps/server/src/conversation-plan/policy-final.ts b/apps/server/src/conversation-plan/policy-final.ts new file mode 100644 index 00000000..8ff4a4c4 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-final.ts @@ -0,0 +1,64 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import type { PolicyContext } from "./policy-context"; +import { OWNED_QUOTE_MIN } from "./policy-cues"; +import { stableId } from "./policy-identity"; +import { noul, roleChoice } from "./policy-scoring"; +import type { Event, PolicyResult } from "./policy-types"; + +/** Complete only after the original loop exits; fallback validation retains working unchanged. */ +export function finishPolicy(context: PolicyContext): PolicyResult { + let { + input, + channelId, + message, + first, + events, + reviews, + outcomes, + working, + selectedTarget, + directQuestion, + } = context; + if (directQuestion && !events.length) { + let ownedQuestion = input.candidates.findIndex((candidate, index) => + noul(first, `c${index}_owned_unretracted`) >= OWNED_QUOTE_MIN + && roleChoice(candidate.answers, first) === "question" + ); + if (ownedQuestion >= 0) { + let threadId = `thread:${stableId(channelId, message.id, 0, "thread").slice(11)}`; + let opening: Event = { + id: stableId(channelId, message.id, 0, "thread.opened"), + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: message.author.kind === "agent" ? "planner" : "classifier", + actor: message.author.kind === "agent" ? { kind: "agent" } : { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "question", + }, + question: message.text, + }; + try { + applyInference(working, opening, message); + events.push(opening); + outcomes[ownedQuestion]!.eventIds.push(opening.id); + outcomes[ownedQuestion]!.status = "accepted"; + outcomes[ownedQuestion]!.gate = "accepted"; + } catch { + outcomes[ownedQuestion]!.status = "review"; + outcomes[ownedQuestion]!.gate = "question could not be opened"; + } + } + } + let policyGate = outcomes.length + ? outcomes.map((outcome, index) => `${index}:${outcome.gate}`).join("; ").slice(0, 300) + : "no source candidates"; + return { events, selectedTarget, candidates: reviews, outcomes, policyGate }; +} diff --git a/apps/server/src/conversation-plan/policy-identity.test.ts b/apps/server/src/conversation-plan/policy-identity.test.ts new file mode 100644 index 00000000..ccb768b1 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-identity.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import { optionIdFor } from "./policy-identity"; + +// Exact callback: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/pipeline.test.ts. +// Remaining pipeline callbacks and the D19 policy callback await the complete policy. + +test("classifier option IDs are deterministic ULIDs using the message's Unix seconds", () => { + let id = optionIdFor("channel", "m1", 0, 1_000); + expect(id).toMatch(/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/); + expect(optionIdFor("channel", "m1", 0, 1_000)).toBe(id); + expect(optionIdFor("channel", "m1", 1, 1_000)).not.toBe(id); + let alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + let milliseconds = 0; + for (let character of id.slice(0, 10)) { + milliseconds = milliseconds * 32 + alphabet.indexOf(character); + } + expect(milliseconds).toBe(1_000_000); +}); diff --git a/apps/server/src/conversation-plan/policy-identity.ts b/apps/server/src/conversation-plan/policy-identity.ts new file mode 100644 index 00000000..02eef6c4 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-identity.ts @@ -0,0 +1,47 @@ +import { createHash } from "node:crypto"; +import { QUESTION_SET_VERSION } from "./question-shared"; + +export function stableId( + channelId: string, + messageId: string, + index: number, + kind: string, +): string { + let digest = createHash("sha256").update(JSON.stringify([ + channelId, + messageId, + index, + kind, + QUESTION_SET_VERSION, + ])).digest("hex").slice(0, 24); + return `classifier:${digest}`; +} + +export const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/** Card option identity uses the message time and deterministic message-derived randomness. */ +export function optionIdFor( + channelId: string, + messageId: string, + index: number, + at: number, +): string { + let digest = createHash("sha256").update(JSON.stringify([ + channelId, + messageId, + index, + "option", + QUESTION_SET_VERSION, + ])).digest(); + let time = ""; + let milliseconds = Math.max(0, Math.floor(at * 1_000)); + for (let position = 0; position < 10; position++) { + time = CROCKFORD[milliseconds % 32] + time; + milliseconds = Math.floor(milliseconds / 32); + } + let random = ""; + for (let position = 0; position < 16; position++) { + random += CROCKFORD[digest[position]! % 32]; + } + return time + random; +} diff --git a/apps/server/src/conversation-plan/policy-initial-terminals.test.ts b/apps/server/src/conversation-plan/policy-initial-terminals.test.ts new file mode 100644 index 00000000..6804e742 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-initial-terminals.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { createPolicyContext } from "./policy-context"; +import { runInitialTerminals } from "./policy-initial-terminals"; +import { inputFor, prefix, terminalPolicy } from "./policy-initial.test-fixtures"; +import { MAX_EVENTS } from "./validation"; +import type { Event } from "./policy-types"; + +let compound = "We need to choose how people sign in and how agents get credentials."; + +test("quote budget rejects more than four candidates", () => { + let input = inputFor(compound); + input.candidates = Array.from({ length: 5 }, () => input.candidates[0]!); + expect(() => createPolicyContext(input)).toThrow("source quote count exceeds 4"); +}); + +test.each(["streaming", "empty", "system"] as const)( + "ineligible %s returns no events", + kind => { + let input = inputFor(compound); + if (kind === "streaming") input.message.streaming = true; + if (kind === "empty") input.message.text = ""; + if (kind === "system") input.message.author = { kind: "system" }; + let context = createPolicyContext(input); + expect(runInitialTerminals(context)).toEqual({ + events: [], + candidates: [], + outcomes: [], + policyGate: "ineligible message", + }); + expect(context.facts).toBeUndefined(); + }, +); + +test("attributed compound questions return review without opening threads", () => { + let input = inputFor( + "Alice said we need to choose how people sign in and how agents get credentials.", + ); + let before = structuredClone(input); + let result = terminalPolicy(input); + expect(result.policyGate).toBe("compound decision attribution unclear"); + expect(result.events).toEqual([]); + expect(result.outcomes.map(outcome => outcome.status)).toEqual(["review", "review"]); + expect(input).toEqual(before); +}); + +test("owned compound questions stage two exact openings atomically", () => { + let input = inputFor(compound); + let before = structuredClone(input); + let result = terminalPolicy(input); + expect(result.policyGate).toBe("explicit compound questions accepted"); + expect(result.events.map(event => event.type)).toEqual(["thread.opened", "thread.opened"]); + expect(result.events.map(event => "source" in event ? event.source : undefined)).toEqual( + input.candidates.map(candidate => ({ + messageId: input.message.id, + author: input.message.author as ConversationPlan.SourceAuthor, + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + role: "question", + })), + ); + expect(result.selectedTarget).toBe(result.events[0]!.threadId); + expect(result.outcomes.map(outcome => outcome.eventIds)).toEqual( + result.events.map(event => [event.id]), + ); + expect(input).toEqual(before); +}); + +test("second compound opening failure discards the staged first opening and continues", () => { + let input = inputFor(compound); + // The event cap is exercised without inventing thousands of domain transitions. + input.state.events = Array.from({ length: MAX_EVENTS - 1 }, (_, index): Event => ({ + id: `historical-${index}`, + type: "thread.opened", + threadId: `historical-thread-${index}`, + observedThreadVersion: 0, + origin: "human", + actor: { kind: "member", handle: "Mina" }, + at: 999, + question: "Historical question?", + })); + let before = structuredClone(input); + let context = createPolicyContext(input); + expect(runInitialTerminals(context)).toBeUndefined(); + expect(context.events).toEqual([]); + expect(context.working).toBe(input.state); + expect(input).toEqual(before); +}); + +test("eligible unhandled inputs expose facts and explicitly continue", () => { + let input = inputFor("Useful chatter."); + let context = createPolicyContext(input); + expect(runInitialTerminals(context)).toBeUndefined(); + expect(context.facts?.exactDirectAlternatives).toBe(false); + expect(context.events).toEqual([]); + expect(prefix(input)).toBeUndefined(); +}); + +test("continuation retains the quoted-option closure from the original message capture", () => { + let input = inputFor("Should audit logs go in PostgreSQL or object storage?"); + let context = createPolicyContext(input); + expect(runInitialTerminals(context)).toBeUndefined(); + context.message = { ...context.message, id: "changed-after-terminal-pass" }; + let option = context.quotedOption!(input.candidates[0]!, 0, "audit", input.state); + expect(option.type).toBe("option.added"); + expect("source" in option && option.source?.messageId).toBe(input.message.id); +}); diff --git a/apps/server/src/conversation-plan/policy-initial-terminals.ts b/apps/server/src/conversation-plan/policy-initial-terminals.ts new file mode 100644 index 00000000..6c8d9125 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-initial-terminals.ts @@ -0,0 +1,28 @@ +import { + compoundAttribution, + compoundOpenings, + questionListCorroboration, +} from "./policy-compound"; +import { directFacts, type PolicyContext } from "./policy-context"; +import { createQuotedOption, directRecovery } from "./policy-direct-recovery"; +import type { PolicyResult } from "./policy-types"; + +/** Requires createPolicyContext first. Undefined continues beyond these four terminal checks. */ +export function runInitialTerminals(context: PolicyContext): PolicyResult | undefined { + let { message, events, reviews, outcomes } = context; + if (message.streaming || !message.text || message.author.kind === "system") { + return { events, candidates: reviews, outcomes, policyGate: "ineligible message" }; + } + + let facts = directFacts(context); + context.facts = facts; + let result = questionListCorroboration(context, facts); + if (result) return result; + let quotedOption = createQuotedOption(context); + context.quotedOption = quotedOption; + result = compoundAttribution(context); + if (result) return result; + result = compoundOpenings(context); + if (result) return result; + return directRecovery(context, facts, quotedOption); +} diff --git a/apps/server/src/conversation-plan/policy-initial.test-fixtures.ts b/apps/server/src/conversation-plan/policy-initial.test-fixtures.ts new file mode 100644 index 00000000..a2a149db --- /dev/null +++ b/apps/server/src/conversation-plan/policy-initial.test-fixtures.ts @@ -0,0 +1,157 @@ +import { expect } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import { createPolicyContext } from "./policy-context"; +import { runInitialTerminals } from "./policy-initial-terminals"; +import type { PolicyInput, PolicyResult } from "./policy-types"; +import { extractQuotes } from "./quotes"; + +// Preserved from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/pipeline.test.ts. +export let confidentChoice = (choice: string): any => ({ + type: "choice", + choice, + confidence: 0.95, + probabilities: { [choice]: 0.95, ...(choice === "none" ? { other: 0.05 } : { none: 0.05 }) }, +}); +export let member = (handle: string): Extract => ({ + kind: "member", + handle, +}); +export let message = (id: string, text: string, handle = "Mina"): Chat.Entry => ({ + id, + text, + author: member(handle), + ts: 1000, +}); +export function first(nouls: Record = {}): Record { + return Object.fromEntries( + Object.entries({ + c0_owned_unretracted: 0.95, + c1_owned_unretracted: 0.95, + c2_owned_unretracted: 0.95, + ...nouls, + }).map(([key, noul]) => [key, { type: "noul", noul }]), + ); +} +export function follow( + options: { + role: string; + thread: string; + threadProbability?: number; + explicit_resolution?: number; + reopening?: number; + material_objection?: number; + }, +): Record { + let p = options.threadProbability ?? 0.95; + return { + role: { + type: "choice", + choice: options.role, + confidence: p, + probabilities: options.role === "none" + ? { none: p, support: 1 - p } + : { [options.role]: p, none: 1 - p }, + }, + thread: { + type: "choice", + choice: options.thread, + confidence: p, + probabilities: { [options.thread]: p, none: 1 - p }, + }, + explicit_resolution: { type: "noul", noul: options.explicit_resolution ?? 0 }, + reopening: { type: "noul", noul: options.reopening ?? 0 }, + material_objection: { type: "noul", noul: options.material_objection ?? 0 }, + new_option: { type: "noul", noul: options.role === "option" ? 0.95 : 0.05 }, + planning_substance: { + type: "noul", + noul: ["reason", "constraint"].includes(options.role) ? 0.95 : 0.05, + }, + support: { type: "noul", noul: options.role === "support" ? 0.95 : 0.05 }, + objection: { type: "noul", noul: options.role === "objection" ? 0.95 : 0.05 }, + }; +} + +export function prefix(input: PolicyInput): PolicyResult | undefined { + return runInitialTerminals(createPolicyContext(input)); +} + +// Original callbacks call planEvents; this test adapter requires one of the extracted terminals. +export function terminalPolicy(input: PolicyInput): PolicyResult { + let result = prefix(input); + expect(result).toBeDefined(); + if (!result) throw new Error("fixture continues beyond initial policy terminals"); + return result; +} + +export function inputFor(text: string): PolicyInput { + let current = message("initial-policy", text); + return { + channelId: "channel", + message: current, + state: initialState(), + first: { + ...first({ new_question: 0.95, enough_purpose: 0.95 }), + act: confidentChoice("question"), + thread_target: confidentChoice("new"), + }, + candidates: extractQuotes(text).map(quote => ({ + ...quote, + answers: { + ...follow({ role: "question", thread: "new" }), + duplicate: { type: "noul", noul: 0 }, + }, + })), + }; +} + +export function addMatchingThread(input: PolicyInput, threadId: string): ConversationPlan.State { + let original = { ...input.message, id: `original-${threadId}` }; + return applyInference(input.state, { + id: `opening-${threadId}`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: original.ts, + source: { + messageId: original.id, + author: original.author as ConversationPlan.SourceAuthor, + quote: original.text, + start: 0, + end: original.text.length, + role: "question", + }, + question: original.text, + }, original); +} + +export function seedOptions(input: PolicyInput, count: number): void { + for (let [index, candidate] of input.candidates.slice(0, count).entries()) { + let thread = input.state.threads[0]!; + input.state = applyInference(input.state, { + id: `prior-option-${index}`, + type: "option.added", + threadId: thread.id, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: input.message.ts, + source: { + messageId: input.message.id, + author: input.message.author as ConversationPlan.SourceAuthor, + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + role: "option", + }, + contribution: { + id: `prior-contribution-${index}`, + text: candidate.quote, + authoring: "quoted", + }, + }, input.message); + } +} diff --git a/apps/server/src/conversation-plan/policy-proposal.ts b/apps/server/src/conversation-plan/policy-proposal.ts new file mode 100644 index 00000000..68876baf --- /dev/null +++ b/apps/server/src/conversation-plan/policy-proposal.ts @@ -0,0 +1,36 @@ +import { runWithdrawal } from "./policy-candidate-withdrawal"; +import { runAgreement } from "./policy-candidate-agreement"; +import { runQuestion } from "./policy-candidate-question"; +import { runContribution } from "./policy-candidate-contribution"; +import { runStance } from "./policy-candidate-stance"; +import { runResolution } from "./policy-candidate-resolution"; +import { runReopening } from "./policy-candidate-reopening"; +import type { CandidateEntry } from "./policy-candidate-entry"; +import type { CandidateRole } from "./policy-candidate-role"; +import type { PolicyContext } from "./policy-context"; +import type { Event } from "./policy-types"; + +/** Private role routing preserves handled skips separately from a missing proposal. */ +export function proposeCandidate( + context: PolicyContext, + entry: CandidateEntry, + frame: CandidateRole, +): { proposed: Event | undefined } | undefined { + let { role } = frame; + if (role === "withdrawal") { + return runWithdrawal(context, entry, frame); + } else if (role === "none") { + return runAgreement(context, entry, frame); + } else if (role === "question") { + return runQuestion(context, entry, frame); + } else if (["option", "reason", "constraint"].includes(role)) { + return runContribution(context, entry, frame); + } else if (role === "support" || role === "objection") { + return runStance(context, entry, frame); + } else if (role === "resolution") { + return runResolution(context, entry, frame); + } else if (role === "reopening") { + return runReopening(context, entry, frame); + } + return { proposed: undefined }; +} diff --git a/apps/server/src/conversation-plan/policy-purpose-question.ts b/apps/server/src/conversation-plan/policy-purpose-question.ts new file mode 100644 index 00000000..d2e014c5 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-purpose-question.ts @@ -0,0 +1,100 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference } from "./domain"; +import type { PolicyContext } from "./policy-context"; +import { OWNED_QUOTE_MIN } from "./policy-cues"; +import { stableId } from "./policy-identity"; +import { choice, noul, roleChoice } from "./policy-scoring"; +import type { Event, PolicyResult } from "./policy-types"; + +export function purposeAndQuestion(context: PolicyContext): PolicyResult | undefined { + let { input, channelId, message, first, reviews } = context; + let [purpose, finalQuestion] = input.candidates; + let finalTarget = finalQuestion?.answers.thread; + let newProbability = finalTarget?.type === "choice" + ? finalTarget.probabilities.new ?? 0 + : 0; + let competingProbability = finalTarget?.type === "choice" + ? Math.max( + 0, + ...Object.entries(finalTarget.probabilities) + .filter(([key]) => key !== "new").map(([, probability]) => probability), + ) + : 0; + let purposeRole = purpose && roleChoice(purpose.answers, first); + let purposeUsefulProbability = purpose?.answers.role?.type === "choice" + ? Math.max( + 0, + ...Object.entries(purpose.answers.role.probabilities) + .filter(([role]) => role !== "none").map(([, probability]) => probability), + ) + : 1; + let purposeQuestion = message.author.kind === "member" + && input.state.threads.length === 0 + && message.text.length <= 500 && input.candidates.length === 2 + && purpose?.start === 0 && finalQuestion?.end === message.text.length + && purpose.end < finalQuestion.start + && /^\s+$/.test(message.text.slice(purpose.end, finalQuestion.start)) + && message.text.slice(purpose.start, purpose.end) === purpose.quote + && message.text.slice(finalQuestion.start, finalQuestion.end) === finalQuestion.quote + && /^\s*(?:(?:we|i)\s+)?need to\s+(?:pick|choose|select|decide on)\b[^?]{2,300}\.$/i + .test(purpose.quote) + && /^(?:what|which|how)\b[^.!?]{3,150}\?$/i.test(finalQuestion.quote) + && !/["“”‘’🙄]/u.test(message.text) + && !/\b(?:said|asked|told|claimed|reported|according to|if|unless|maybe|perhaps)\b/i + .test(message.text) + && noul(first, "new_question") >= 0.8 + && noul(first, "enough_purpose") >= 0.8 + && choice(first, "act") === "question" + && choice(first, "thread_target") === "new" + && noul(first, "c0_owned_unretracted") >= OWNED_QUOTE_MIN + && noul(first, "c1_owned_unretracted") >= OWNED_QUOTE_MIN + && (purposeRole === undefined || purposeRole === "none") + && purposeUsefulProbability < 0.8 + && purpose.answers.role?.type === "choice" + && (purpose.answers.role.probabilities.question ?? 0) < 0.2 + && roleChoice(finalQuestion.answers, first) === "question" + && finalQuestion.answers.role?.type === "choice" + && (finalQuestion.answers.role.probabilities.question ?? 0) >= 0.9 + && finalTarget?.type === "choice" && finalTarget.choice === "new" + && newProbability >= 0.6 && newProbability - competingProbability >= 0.2; + if (purposeQuestion) { + let threadId = `thread:${stableId(channelId, message.id, 1, "thread").slice(11)}`; + let opening: Event = { + id: stableId(channelId, message.id, 1, "thread.opened"), + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + source: { + messageId: message.id, + author: message.author as ConversationPlan.SourceAuthor, + quote: message.text, + start: 0, + end: message.text.length, + role: "question", + }, + question: message.text, + }; + try { + applyInference(input.state, opening, message); + return { + events: [opening], + selectedTarget: threadId, + candidates: reviews, + outcomes: input.candidates.map((candidate, index) => ({ + start: candidate.start, + end: candidate.end, + status: index === 1 ? "accepted" : "ignored", + gate: index === 1 ? "accepted" : "opening purpose", + eventIds: index === 1 ? [opening.id] : [], + ...(index === 1 ? { targetId: threadId } : {}), + })), + policyGate: "owned purpose and final question accepted", + }; + } catch { + // Let the ordinary candidate gates report why the opening was rejected. + } + } +} diff --git a/apps/server/src/conversation-plan/policy-remaining-terminals.test.ts b/apps/server/src/conversation-plan/policy-remaining-terminals.test.ts new file mode 100644 index 00000000..fbdbcb51 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-remaining-terminals.test.ts @@ -0,0 +1,157 @@ +import { expect, test } from "bun:test"; +import { inputFor } from "./policy-initial.test-fixtures"; +import { runRemainingTerminals } from "./policy-remaining-terminals"; +import { + capEvents, + d01RecordedOpening, + declarativeInput, + prepareRemaining, + remainingPrefix, + stateWithOption, + terminalPolicy, +} from "./policy-terminal.test-fixtures"; +import { MAX_EVENTS, MAX_THREADS } from "./validation"; + +let question = "Should audit logs go in PostgreSQL or object storage?"; + +test.each(["member", "agent"] as const)( + "direct alternatives retain exact options and working state for %s", + kind => { + let input = inputFor(question); + if (kind === "agent") input.message.author = { kind: "agent" }; + let before = structuredClone(input); + let context = prepareRemaining(input); + let result = runRemainingTerminals(context)!; + expect(result.policyGate).toBe("direct alternatives accepted"); + expect(result.events.map(event => event.type)).toEqual([ + "thread.opened", + "option.added", + "option.added", + ]); + expect(result.events.map(event => event.origin)).toEqual( + Array(3).fill(kind === "agent" ? "planner" : "classifier"), + ); + expect(result.outcomes.map(outcome => outcome.eventIds.length)).toEqual([2, 1]); + expect(context.working.events).toEqual(result.events); + expect(context.directQuestion).toBe(true); + expect(input).toEqual(before); + expect(terminalPolicy(input)).toEqual(result); + }, +); + +test("direct opening rejection preserves the original working reference", () => { + let input = inputFor(question); + let seed = stateWithOption("Unrelated question?", "seed", "seed-option", "Seed option."); + input.state.threads = Array.from( + { length: MAX_THREADS }, + (_, index) => ({ ...seed.threads[0]!, id: `seed-${index}` }), + ); + let context = prepareRemaining(input); + expect(runRemainingTerminals(context)?.policyGate).toBe("direct question rejected"); + expect(context.working).toBe(input.state); +}); + +test("direct option rejection retains successful opening and earlier addition in working", () => { + let input = inputFor(question); + capEvents(input, MAX_EVENTS - 2); + let before = structuredClone(input); + let context = prepareRemaining(input); + let result = runRemainingTerminals(context)!; + expect(result.policyGate).toBe("direct option rejected"); + expect(result.events).toEqual([]); + expect(result.outcomes.map(outcome => outcome.status)).toEqual(["review", "review"]); + expect(context.working.events).toHaveLength(MAX_EVENTS); + expect(context.working.threads[0]?.contributions).toHaveLength(1); + expect(context.working).not.toBe(input.state); + expect(input).toEqual(before); +}); + +test("quoted-option errors propagate while direct working retains earlier successes", () => { + let input = inputFor(question); + let context = prepareRemaining(input); + let original = context.quotedOption!; + context.quotedOption = (...args) => { + if (args[1] === 1) throw new Error("quoted option failure"); + return original(...args); + }; + expect(() => runRemainingTerminals(context)).toThrow("quoted option failure"); + expect(context.working.events.map(event => event.type)).toEqual([ + "thread.opened", + "option.added", + ]); + expect(input.state.events).toEqual([]); +}); + +test("declarative batch rejection leaves staged state local", () => { + let input = declarativeInput(); + capEvents(input, MAX_EVENTS - 1); + let before = structuredClone(input); + let context = prepareRemaining(input); + let result = runRemainingTerminals(context)!; + expect(result.policyGate).toBe("declarative options rejected"); + expect(result.events).toEqual([]); + expect(context.working).toBe(input.state); + expect(input).toEqual(before); +}); + +test("declarative quoted-option errors stay outside inference catches", () => { + let input = declarativeInput(); + let context = prepareRemaining(input); + let original = context.quotedOption!; + context.quotedOption = (...args) => { + if (args[1] === 1) throw new Error("quoted option failure"); + return original(...args); + }; + expect(() => runRemainingTerminals(context)).toThrow("quoted option failure"); + expect(context.working).toBe(input.state); +}); + +test("purpose acceptance validates without replacing context working", () => { + let input = d01RecordedOpening(); + let context = prepareRemaining(input); + expect(runRemainingTerminals(context)?.policyGate).toBe( + "owned purpose and final question accepted", + ); + expect(context.working).toBe(input.state); + expect(input.state.threads).toEqual([]); +}); + +test("purpose opening rejection continues without assigning working", () => { + let input = d01RecordedOpening(); + capEvents(input, MAX_EVENTS); + let context = prepareRemaining(input); + expect(runRemainingTerminals(context)).toBeUndefined(); + expect(context.working).toBe(input.state); + expect(context.directQuestion).toBe(false); + expect(remainingPrefix(input)).toBeUndefined(); +}); + +test("directQuestion survives continuation when bounds fail and later inputs change", () => { + let input = inputFor("Should audit logs go in PostgreSQL or PostgreSQL?"); + let context = prepareRemaining(input); + expect(context.facts?.exactDirectAlternatives).toBe(true); + expect(context.facts?.directBounds).toBe(false); + expect(runRemainingTerminals(context)).toBeUndefined(); + expect(context.directQuestion).toBe(true); + context.working = stateWithOption("Other question?", "other", "other-option", "Other option."); + context.first.new_question = { type: "noul", noul: 0 }; + context.facts!.exactDirectAlternatives = false; + expect(context.directQuestion).toBe(true); + expect(remainingPrefix(input)).toBeUndefined(); +}); + +test("remaining stages and cached options use the initially captured message", () => { + let input = inputFor(question); + let original = input.message; + let context = prepareRemaining(input); + input.message = { + ...input.message, + id: "changed-message", + text: "Changed after initialization.", + }; + let result = runRemainingTerminals(context)!; + expect(result.policyGate).toBe("direct alternatives accepted"); + expect(result.events.map(event => "source" in event ? event.source?.messageId : undefined)) + .toEqual(Array(3).fill(original.id)); + expect(result.events[0]).toMatchObject({ question: original.text }); +}); diff --git a/apps/server/src/conversation-plan/policy-remaining-terminals.ts b/apps/server/src/conversation-plan/policy-remaining-terminals.ts new file mode 100644 index 00000000..c83f8f27 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-remaining-terminals.ts @@ -0,0 +1,22 @@ +import type { PolicyContext } from "./policy-context"; +import { declarativePair } from "./policy-declarative"; +import { directAlternatives } from "./policy-direct-alternatives"; +import { purposeAndQuestion } from "./policy-purpose-question"; +import { choice, noul } from "./policy-scoring"; +import type { PolicyResult } from "./policy-types"; + +/** Run after initial terminals continue; facts and quotedOption retain their original captures. */ +export function runRemainingTerminals(context: PolicyContext): PolicyResult | undefined { + let { message, first } = context; + let { exactDirectAlternatives, matchingThreads } = context.facts!; + let directQuestion = exactDirectAlternatives && !matchingThreads.length + && message.text.length <= 500 && noul(first, "new_question") >= 0.9 + && choice(first, "act") === "question" && choice(first, "thread_target") === "new"; + + context.directQuestion = directQuestion; + let result = directAlternatives(context, directQuestion); + if (result) return result; + result = declarativePair(context); + if (result) return result; + return purposeAndQuestion(context); +} diff --git a/apps/server/src/conversation-plan/policy-scoring.ts b/apps/server/src/conversation-plan/policy-scoring.ts new file mode 100644 index 00000000..9e721c06 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-scoring.ts @@ -0,0 +1,62 @@ +import type { JevAnswer } from "./jev"; + +export function noul(answers: Record, key: string): number { + let answer = answers[key]; + return answer?.type === "noul" ? answer.noul : 0; +} +export function choice(answers: Record, key: string): string | undefined { + let answer = answers[key]; + if (answer?.type !== "choice") return; + let ranked = Object.values(answer.probabilities).sort((a, b) => b - a); + if ((ranked[0] ?? 0) < 0.8 || (ranked[0] ?? 0) - (ranked[1] ?? 0) < 0.2) return; + return answer.choice; +} +export function moderateChoice( + answers: Record, + key: string, + expected: string, + minimum: number, +): boolean { + let answer = answers[key]; + if (answer?.type !== "choice" || answer.choice !== expected || answer.confidence < 0.5) { + return false; + } + let probability = answer.probabilities[expected] ?? 0; + let other = Math.max( + 0, + ...Object.entries(answer.probabilities) + .filter(([name]) => name !== expected).map(([, value]) => value), + ); + return probability >= minimum && probability - other >= 0.2; +} +export function roleChoice( + answers: Record, + first: Record, + pendingAgreement = false, +): string | undefined { + let answer = answers.role; + if (answer?.type !== "choice") return; + let role = answer.choice; + if (["question", "resolution", "reopening", "none"].includes(role)) { + return choice(answers, "role"); + } + let significance = first.significance; + if (significance?.type === "score" && significance.score < 1) return; + let corroboration: Record = { + option: ["new_option", 0.75], + reason: ["planning_substance", 0.7], + constraint: ["planning_substance", 0.65], + support: ["support", 0.65], + objection: ["objection", 0.55], + }; + let requirement = corroboration[role]; + let strength = role === "support" && pendingAgreement + ? Math.max(noul(answers, "support"), noul(answers, "agrees_with_settle")) + : requirement + ? noul(answers, requirement[0]) + : 0; + if (!requirement || strength < requirement[1]) return; + let ranked = Object.values(answer.probabilities).sort((a, b) => b - a); + if ((ranked[0] ?? 0) < 0.55 || (ranked[0] ?? 0) - (ranked[1] ?? 0) < 0.1) return; + return role; +} diff --git a/apps/server/src/conversation-plan/policy-terminal.test-fixtures.ts b/apps/server/src/conversation-plan/policy-terminal.test-fixtures.ts new file mode 100644 index 00000000..1de3d6b4 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-terminal.test-fixtures.ts @@ -0,0 +1,177 @@ +import { expect } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyInference, initialState } from "./domain"; +import type { JevAnswer } from "./jev"; +import { createPolicyContext, type PolicyContext } from "./policy-context"; +import { runInitialTerminals } from "./policy-initial-terminals"; +import { runRemainingTerminals } from "./policy-remaining-terminals"; +import { confidentChoice, first, follow, inputFor, message } from "./policy-initial.test-fixtures"; +import type { Event, PolicyInput, PolicyResult } from "./policy-types"; +import { extractQuotes } from "./quotes"; + +// Preserved from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/pipeline.test.ts. +export function d01RecordedOpening(): PolicyInput { + let current = message( + "d01-m1", + "need to pick an editor before comments get any deeper. what are we comparing?", + "Nia", + ); + let quotes = extractQuotes(current.text); + let firstPass: Record = { + ...first({ + new_question: 0.88, + enough_purpose: 0.89, + c0_owned_unretracted: 0.93, + c1_owned_unretracted: 0.95, + }), + act: { + type: "choice" as const, + choice: "question", + confidence: 0.84, + probabilities: { question: 0.87, proposal: 0.13 }, + }, + thread_target: { + type: "choice" as const, + choice: "new", + confidence: 0.99, + probabilities: { new: 1, none: 0 }, + }, + }; + let candidates: PolicyInput["candidates"] = quotes.map((quote, index) => ({ + ...quote, + answers: { + ...follow({ role: index ? "question" : "resolution", thread: "new" }), + role: { + type: "choice" as const, + choice: index ? "question" : "resolution", + confidence: index ? 0.97 : 0.65, + probabilities: (index + ? { question: 0.98, none: 0.02 } + : { resolution: 0.68, question: 0.01, none: 0.31 }) as Record, + }, + thread: { + type: "choice" as const, + choice: "new", + confidence: index ? 0.32 : 0.65, + probabilities: index ? { new: 0.66, none: 0.34 } : { new: 0.83, none: 0.17 }, + }, + planning_substance: { type: "noul" as const, noul: index ? 0.15 : 0.85 }, + }, + })); + return { + channelId: "channel", + message: current, + state: initialState(), + first: firstPass, + candidates, + }; +} +export function stateWithOption( + questionText: string, + threadId: string, + optionId: string, + optionText: string, +): ConversationPlan.State { + return addThreadWithOption(initialState(), threadId, questionText, optionId, optionText); +} +export function addThreadWithOption( + state: ConversationPlan.State, + threadId: string, + questionText: string, + optionId: string, + optionText: string, +): ConversationPlan.State { + let questionMessage = message(`question-${threadId}`, questionText); + let withQuestion = applyInference(state, { + id: `open-${threadId}`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: questionMessage.ts, + source: { + messageId: questionMessage.id, + author: questionMessage.author as ConversationPlan.SourceAuthor, + quote: questionMessage.text, + start: 0, + end: questionMessage.text.length, + role: "question", + }, + question: questionMessage.text, + }, questionMessage); + let optionMessage = message(`option-${threadId}`, optionText); + return applyInference(withQuestion, { + id: `option-${threadId}`, + type: "option.added", + threadId, + observedThreadVersion: withQuestion.threads.find(item => item.id === threadId)!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: optionMessage.ts, + source: { + messageId: optionMessage.id, + author: optionMessage.author as ConversationPlan.SourceAuthor, + quote: optionMessage.text, + start: 0, + end: optionMessage.text.length, + role: "option", + }, + contribution: { + id: optionId, + text: optionText, + authoring: "quoted", + targetId: threadId, + }, + }, optionMessage); +} + +export function remainingPrefix(input: PolicyInput): PolicyResult | undefined { + let context = createPolicyContext(input); + let initial = runInitialTerminals(context); + if (initial) return initial; + return runRemainingTerminals(context); +} + +// The retained callbacks need a terminal result, not a complete-policy implementation. +export function terminalPolicy(input: PolicyInput): PolicyResult { + let result = remainingPrefix(input); + expect(result).toBeDefined(); + if (!result) throw new Error("fixture continues beyond remaining policy terminals"); + return result; +} + +export function prepareRemaining(input: PolicyInput): PolicyContext { + let context = createPolicyContext(input); + expect(runInitialTerminals(context)).toBeUndefined(); + return context; +} + +export function declarativeInput(): PolicyInput { + let input = inputFor("By agent access I mean Copilot or bring-your-own API keys."); + input.state = stateWithOption( + "How should we provide agent access?", + "access", + "gateway", + "Use a hosted gateway.", + ); + input.first.new_option = { type: "noul", noul: 0.95 }; + input.first.thread_target = confidentChoice("access"); + for (let candidate of input.candidates) candidate.answers.thread = confidentChoice("access"); + return input; +} + +// Exercise event counts without claiming that the synthetic history is replay-consistent. +export function capEvents(input: PolicyInput, count: number): void { + input.state.events = Array.from({ length: count }, (_, index): Event => ({ + id: `historical-${index}`, + type: "thread.opened", + threadId: `historical-thread-${index}`, + observedThreadVersion: 0, + origin: "human", + actor: { kind: "member", handle: "Mina" }, + at: 999, + question: "Historical question?", + })); +} diff --git a/apps/server/src/conversation-plan/policy-topic.test.ts b/apps/server/src/conversation-plan/policy-topic.test.ts new file mode 100644 index 00000000..c1dd5080 --- /dev/null +++ b/apps/server/src/conversation-plan/policy-topic.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from "bun:test"; +import { initialState } from "./domain"; +import { directAlternativeQuotes, extractQuotes } from "./quotes"; +import { confidentChoice, first, follow, message } from "./policy-initial.test-fixtures"; +import { planEvents } from "./policy"; + +// Preserved from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/pipeline.test.ts. +test("high-confidence option labels cannot turn D03's question or grouped list into choices", () => { + let text = "what sends transactional notifications? our SMTP relay, Postmark, or SES?"; + let current = message("d03-wrong-option-spans", text, "Liv"); + let quotes = [ + { quote: text.slice(0, 39), start: 0, end: 39 }, + { quote: text.slice(40, 73), start: 40, end: 73 }, + ]; + let result = planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: { + ...first({ new_question: 0.97, new_option: 0.99 }), + act: confidentChoice("question"), + thread_target: confidentChoice("new"), + significance: { + type: "score", + score: 2, + confidence: 0.95, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0.05, "2": 0.9, "3": 0.05 }, + }, + }, + candidates: quotes.map(quote => ({ + ...quote, + answers: { + ...follow({ role: "option", thread: "new" }), + option: confidentChoice("new"), + chosen_option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.99 }, + planning_substance: { type: "noul", noul: 0.99 }, + duplicate: { type: "noul", noul: 0.01 }, + }, + })), + }); + expect(result.events.filter(event => event.type === "option.added")).toEqual([]); +}); +test.each([ + "Because this is our fallback, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + "When configured, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + "As Fallback, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + "what sends transactional notifications? our SMTP relay, Because, or SES?", + "what sends transactional notifications? our SMTP relay, When configured, or SES?", + "what sends transactional notifications? our SMTP relay, As Fallback, or SES?", + "what sends transactional notifications if cost wins? our SMTP relay, Postmark, or SES?", + "what sends transactional notifications when cost wins? our SMTP relay, Postmark, or SES?", + "what sends transactional notifications according to Dan? our SMTP relay, Postmark, or SES?", + "what sends transactional notifications as Dan reported? our SMTP relay, Postmark, or SES?", + "what sends transactional notifications unless cost wins? our SMTP relay, Postmark, or SES?", + "Dan said what sends transactional notifications? our SMTP relay, Postmark, or SES?", + "what sends transactional notifications? our SMTP relay / Postmark, SES, or Mailgun?", + "what sends transactional notifications? our SMTP relay is cheap, Postmark, or SES?", + "what sends transactional notifications? our SMTP relay because cheap, Postmark, or SES?", +])("strong first-pass answers cannot turn unsafe D03 source spans into options: %s", text => { + let current = message("d03-qualified-list", text, "Liv"); + let quotes = extractQuotes(text); + let result = planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: { + ...first({ new_question: 0.97, new_option: 0.99 }), + act: confidentChoice("question"), + thread_target: confidentChoice("new"), + significance: { + type: "score", + score: 2, + confidence: 0.95, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0.05, "2": 0.9, "3": 0.05 }, + }, + }, + candidates: quotes.map(quote => ({ + ...quote, + answers: { + ...follow({ role: "option", thread: "new" }), + option: confidentChoice("new"), + chosen_option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.99 }, + planning_substance: { type: "noul", noul: 0.99 }, + duplicate: { type: "noul", noul: 0.01 }, + }, + })), + }); + expect(result.events.filter(event => event.type === "option.added")).toEqual([]); +}); +test.each([ + { + name: "inline cost qualifier", + text: + "what sends transactional notifications when cost wins? our SMTP relay, Postmark, or SES?", + candidates: [ + { quote: "SMTP relay", start: 59, end: 69 }, + { quote: "Postmark", start: 71, end: 79 }, + { quote: "SES", start: 84, end: 87 }, + ], + }, + { + name: "fallback rationale before the question", + text: + "Because this is our fallback, what sends transactional notifications? our SMTP relay, Postmark, or SES?", + candidates: [ + { quote: "SMTP relay", start: 74, end: 84 }, + { quote: "Postmark", start: 86, end: 94 }, + { quote: "SES", start: 99, end: 102 }, + ], + }, +])("hand-supplied D03 spans from $name are reviewed atomically", ({ text, candidates }) => { + let current = message("d03-exact-span-bypass", text, "Liv"); + expect(directAlternativeQuotes(text)).toEqual([]); + for (let candidate of candidates) { + expect(text.slice(candidate.start, candidate.end)).toBe(candidate.quote); + } + let result = planEvents({ + channelId: "channel", + message: current, + state: initialState(), + first: { + ...first({ new_question: 0.97, new_option: 0.99 }), + act: confidentChoice("question"), + thread_target: confidentChoice("new"), + significance: { + type: "score", + score: 2, + confidence: 0.95, + legend: { "0": "chatter", "1": "minor", "2": "useful", "3": "work" }, + probabilities: { "0": 0, "1": 0.05, "2": 0.9, "3": 0.05 }, + }, + }, + candidates: candidates.map(candidate => ({ + ...candidate, + answers: { + ...follow({ role: "option", thread: "new" }), + option: confidentChoice("new"), + chosen_option: confidentChoice("new"), + new_option: { type: "noul", noul: 0.99 }, + planning_substance: { type: "noul", noul: 0.99 }, + duplicate: { type: "noul", noul: 0.01 }, + }, + })), + }); + expect(result.events).toEqual([]); + expect(result.outcomes.map(outcome => outcome.status)).toEqual([ + "review", + "review", + "review", + ]); +}); diff --git a/apps/server/src/conversation-plan/policy-types.ts b/apps/server/src/conversation-plan/policy-types.ts new file mode 100644 index 00000000..de59474b --- /dev/null +++ b/apps/server/src/conversation-plan/policy-types.ts @@ -0,0 +1,23 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { JevAnswer } from "./jev"; +import type { QuoteCandidate } from "./quotes"; +import type { LinkedCardOptions } from "./questions"; + +export type Event = ConversationPlan.Event; +export type State = ConversationPlan.State; +export type CandidateJudgment = QuoteCandidate & { answers: Record }; +export type PolicyInput = { + channelId: string; + message: Chat.Entry; + state: State; + linkedCards?: LinkedCardOptions; + first: Record; + candidates: CandidateJudgment[]; +}; +export type PolicyResult = { + events: Event[]; + selectedTarget?: string; + candidates: Array<{ id: string; kind: "resolution" | "reopening"; targetId?: string }>; + outcomes: ConversationPlan.CandidateOutcome[]; + policyGate: string; +}; diff --git a/apps/server/src/conversation-plan/policy.ts b/apps/server/src/conversation-plan/policy.ts new file mode 100644 index 00000000..ddffcb08 --- /dev/null +++ b/apps/server/src/conversation-plan/policy.ts @@ -0,0 +1,80 @@ +import { applyCandidateEvent } from "./policy-candidate-application"; +import { beginCandidate } from "./policy-candidate-entry"; +import { runDirectNewChoice } from "./policy-candidate-new-choice"; +import { captureCandidateRole } from "./policy-candidate-role"; +import { runScopedAssent, runScopedProposal } from "./policy-candidate-scoped"; +import { prepareCandidateRun } from "./policy-candidate-setup"; +import { runCandidateVerification } from "./policy-candidate-verification"; +import { createPolicyContext } from "./policy-context"; +import { finishPolicy } from "./policy-final"; +import { runInitialTerminals } from "./policy-initial-terminals"; +import { proposeCandidate } from "./policy-proposal"; +import { runRemainingTerminals } from "./policy-remaining-terminals"; +import { explicitListQuotes } from "./quotes"; +import type { PolicyInput, PolicyResult } from "./policy-types"; + +export { optionIdFor } from "./policy-identity"; +export type { PolicyInput, PolicyResult } from "./policy-types"; + +export function planEvents(input: PolicyInput): PolicyResult { + let context = createPolicyContext(input); + let terminal = runInitialTerminals(context); + if (terminal) return terminal; + terminal = runRemainingTerminals(context); + if (terminal) return terminal; + prepareCandidateRun(context); + let { events } = context; + for (let [index, candidate] of input.candidates.entries()) { + let entry = beginCandidate(context, index, candidate); + if (!entry) continue; + let frame = captureCandidateRole(context, entry); + if (!frame) continue; + let spike = runCandidateVerification(context, entry, frame); + if (!spike) continue; + if (!runScopedAssent(context, entry, frame)) continue; + if (!runScopedProposal(context, entry, frame, spike)) continue; + if (!runDirectNewChoice(context, entry, frame)) continue; + let proposal = proposeCandidate(context, entry, frame); + if (!proposal) continue; + let { proposed } = proposal; + if (!proposed) continue; + applyCandidateEvent(context, entry, frame, proposed); + if (events.length >= 12) break; + } + let result = finishPolicy(context); + let listed = explicitListQuotes(input.message.text); + let bareList = listed.length >= 3 + && input.message.text.indexOf("?") < 0 + && listed.length === input.candidates.length + && listed.every((quote, index) => + quote.quote === input.candidates[index]?.quote + && quote.start === input.candidates[index]?.start + && quote.end === input.candidates[index]?.end + ); + if ( + bareList && !( + result.events.length === listed.length + && result.outcomes.length === listed.length + && result.outcomes.every(outcome => outcome.status === "accepted") + && result.events.every((event, index) => + event.type === "option.added" + && event.source?.start === listed[index]!.start + && event.source.end === listed[index]!.end + ) + ) + ) { + return { + events: [], + candidates: result.candidates, + outcomes: listed.map(quote => ({ + start: quote.start, + end: quote.end, + status: "review", + gate: "alternative list could not be applied together", + eventIds: [], + })), + policyGate: "alternative list could not be applied together", + }; + } + return result; +} diff --git a/apps/server/src/conversation-plan/refined-option-policy.test.ts b/apps/server/src/conversation-plan/refined-option-policy.test.ts new file mode 100644 index 00000000..531c745c --- /dev/null +++ b/apps/server/src/conversation-plan/refined-option-policy.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import { applyEvent } from "./events"; +import { planEvents } from "./policy"; +import { stanceInput } from "./policy-candidate-stance.test-fixtures"; +import { pendingChoice } from "./policy-candidate-scoped.test-fixtures"; +import { buildCandidateTargetingRequest, buildTriageRequest } from "./questions"; + +function refinedInput(role = "support") { + let input = stanceInput(role, role === "support" ? "I support Beta." : "I oppose Beta."); + let thread = input.state.threads[0]!; + input.state = applyEvent(input.state, { + id: "refined-option", + type: "option.relabeled", + threadId: thread.id, + observedThreadVersion: thread.version, + observedCardRevision: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1000, + optionId: "alpha", + label: "Beta", + }); + input.linkedCards!.get("provider")!.options[0]!.label = "Beta"; + return input; +} + +test.each(["support", "objection"])( + "%s names the refined option while retaining its original evidence", + role => { + let input = refinedInput(role); + let option = structuredClone(input.state.threads[0]!.contributions[0]!); + let result = planEvents(input); + expect(result.events).toMatchObject([{ + type: "stance.changed", + optionId: "alpha", + position: role === "support" ? "support" : "oppose", + source: { quote: input.message.text }, + }]); + expect(input.state.threads[0]!.contributions[0]).toEqual(option); + expect(option).toMatchObject({ text: "Alpha", displayLabel: "Beta" }); + expect(option.sources[0]!.quote).toBe("Alpha"); + }, +); + +test("interpretation context names the refined option and pending choice without changing evidence", () => { + let input = refinedInput(); + pendingChoice(input); + let original = structuredClone(input.state); + let triage = buildTriageRequest( + input.message, + [], + input.state.threads, + input.candidates, + input.state.events, + ); + let targeting = buildCandidateTargetingRequest( + input.message, + [], + input.state.threads, + input.candidates, + 0, + input.state.events, + input.linkedCards, + ); + for (let request of [triage, targeting]) { + let state = request.state as { + threads: Array<{ + options: Array<{ id: string; text: string }>; + pendingSettle?: { optionId: string; option: string }; + }>; + }; + expect(state.threads[0]!.options).toEqual([{ id: "alpha", text: "Beta" }]); + expect(state.threads[0]!.pendingSettle).toMatchObject({ + optionId: "alpha", + option: "Beta", + }); + } + expect(input.state).toEqual(original); +});