diff --git a/lib/behaviour-selection/behaviour-selector.js b/lib/behaviour-selection/behaviour-selector.js index b8b8553..26601da 100644 --- a/lib/behaviour-selection/behaviour-selector.js +++ b/lib/behaviour-selection/behaviour-selector.js @@ -30,10 +30,29 @@ const PRIORITIES = { continue: 5, // default }; +/* ── Acknowledge exclusion gate ─────────────────────────────── */ + +/** + * Deterministic exclusions for Acknowledge. + * Returns true when Acknowledge should not fire, even if its positive trigger matches. + * This gate qualifies the trigger; it does not replace it. + */ +function isAcknowledgeExcluded(assessment) { + // Phase-based exclusions: synthesising and concluding states call for Summarise, not Acknowledge + if (["synthesising", "concluding"].includes(assessment.phase.value)) return true; + // Progress-based exclusion: stalled progress calls for Pause, not Acknowledge + if (assessment.progress.value === "stalled") return true; + // Health-based exclusion: user_overloaded calls for Pause, not Acknowledge + if (assessment.conversationHealth.value === "user_overloaded") return true; + return false; +} + /* ── Selection rules (one rule per behaviour) ─────────────── */ function selectAcknowledge(assessment) { if (assessment.conversationHealth.value === "healthy" && assessment.phase.confidence !== "low") { + // Apply exclusion gate before returning acknowledge + if (isAcknowledgeExcluded(assessment)) return null; return { behaviour: "acknowledge", confidence: "medium", diff --git a/tests/behaviour-selection.reachability.test.js b/tests/behaviour-selection.reachability.test.js index f6a759c..ca77024 100644 --- a/tests/behaviour-selection.reachability.test.js +++ b/tests/behaviour-selection.reachability.test.js @@ -168,12 +168,28 @@ function buildAssessmentInput(turn) { }; } +/* ═══════════════════════════════════════════════════ + Acknowledge exclusion gate — mirrors production rules + ═══════════════════════════════════════════════════ */ + +/** + * Deterministic exclusions for Acknowledge. + * Returns true when Acknowledge should not fire, even if its positive trigger matches. + * Mirrors the production selector's isAcknowledgeExcluded gate. + */ +function isAcknowledgeExcluded(assessment) { + if (["synthesising", "concluding"].includes(assessment.phase.value)) return true; + if (assessment.progress.value === "stalled") return true; + if (assessment.conversationHealth.value === "user_overloaded") return true; + return false; +} + /* ═══════════════════════════════════════════════════ Diagnostic audit helper — checks every rule per turn ═══════════════════════════════════════════════════ */ const BEHAVIOUR_CHECKERS = [ - { key: "acknowledge", fn: (a) => a.conversationHealth.value === "healthy" && a.phase.confidence !== "low" }, + { key: "acknowledge", fn: (a) => a.conversationHealth.value === "healthy" && a.phase.confidence !== "low" && !isAcknowledgeExcluded(a) }, { key: "clarify", fn: (a) => a.conversationHealth.value === "too_broad" || (a.phase.value === "orienting" && (a.phase.evidence?.observationDensity ?? Infinity) < 3) }, { key: "summarise", fn: (a) => a.phase.value === "synthesising" || a.phase.value === "concluding" || (a.phase.evidence?.resolvedNodeCount >= 3 && a.progress.value === "steady") }, { key: "pause", fn: (a) => (a.phase.value === "focusing" && a.progress.value === "stalled") || a.conversationHealth.value === "user_overloaded" }, @@ -341,19 +357,20 @@ describe("Experiment 40 — Behaviour Reachability Diagnostic", () => { return reachability; }); - it("Acknowledge is eligible in 5 of 7 turns and selected in all 5", () => { + it("Acknowledge is eligible in 3 of 7 turns and selected in 3 — excluded by phase/progress gates in 2 others", () => { const reachability = global._exp40_reachability || {}; - expect(reachability.acknowledge?.eligible).toBe(5); - expect(reachability.acknowledge?.selected).toBe(5); + // With exclusion gate in the diagnostic checker: ack is only eligible in non-excluded turns + expect(reachability.acknowledge?.eligible).toBe(3); + expect(reachability.acknowledge?.selected).toBe(3); }); - it("Summarise is eligible in 2 of 7 turns and always blocked", () => { + it("Summarise is eligible in 2 of 7 turns, selected in 1 via the exclusion gate", () => { const reachability = global._exp40_reachability || {}; - // Terminal state has phase=concluding, so summarise IS eligible + // Terminal state has phase=concluding, so summarise IS eligible and now unblocked (ack excluded) expect(reachability.summarise?.eligible).toBe(2); - // But acknowledge (priority 1) fires first because health=healthy - expect(reachability.summarise?.blocked).toBe(2); - expect(reachability.summarise?.blockingBy).toContain("acknowledge"); + expect(reachability.summarise?.selected).toBe(1); + expect(reachability.summarise?.blocked).toBe(1); + // The one blocked instance is in long-investigation t1 (focusing with resolvedNodeCount>=3): ack still wins there because focusing isn't excluded }); it("Clarify is eligible in 0 of 7 turns — never triggered", () => { @@ -361,13 +378,12 @@ describe("Experiment 40 — Behaviour Reachability Diagnostic", () => { expect(reachability.clarify?.eligible).toBe(0); }); - it("Pause is eligible in 1 of 7 turns but always blocked", () => { + it("Pause is eligible in 1 of 7 turns and now selected via the exclusion gate", () => { const reachability = global._exp40_reachability || {}; // Contradictory turn 1: phase=focusing, progress=stalled → pause rule fires + // Acknowledge is excluded here because progress=stalled expect(reachability.pause?.eligible).toBe(1); - // But acknowledge (priority 1) also fires (health=healthy), blocking pause - expect(reachability.pause?.blocked).toBe(1); - expect(reachability.pause?.blockingBy).toContain("acknowledge"); + expect(reachability.pause?.selected).toBe(1); }); it("Continue is eligible in 2 of 7 turns and selected in all 2", () => { @@ -403,13 +419,14 @@ describe("Experiment 40 — Behaviour Reachability Diagnostic", () => { console.log(` ${marker} ${b.behaviour}: eligible=${b.eligible}${b.blockedBy ? ` blocked by ${b.blockedBy}` : ""}`); } - // Summarise should be eligible (concluding phase) but blocked + // Summarise should be eligible (concluding phase) and now selected — ack excluded by gate const summariseAudit = terminalTurn.behaviours.find(b => b.behaviour === "summarise"); expect(summariseAudit.eligible).toBe(true); - expect(summariseAudit.blockedBy).toBe("acknowledge"); + expect(summariseAudit.blockedBy).toBe(null); - // The selected behaviour is Acknowledge - expect(terminalTurn.selectedBehaviour).toBe("acknowledge"); + // The selected behaviour is Summarise — the terminal turn's concluding phase + // now excludes Acknowledge, allowing Summarise to fire at priority 3. + expect(terminalTurn.selectedBehaviour).toBe("summarise"); }); it("prints turn-level detail for pause-eligible turn (contradictory turn 1)", () => { @@ -428,7 +445,8 @@ describe("Experiment 40 — Behaviour Reachability Diagnostic", () => { const pauseAudit = pauseEligibleTurn.behaviours.find(b => b.behaviour === "pause"); expect(pauseAudit.eligible).toBe(true); - expect(pauseAudit.blockedBy).toBe("acknowledge"); + // Acknowledge is excluded (stalled progress) so pause is selected, not blocked + expect(pauseAudit.selected).toBe(true); }); it("Clarify never eligible — no scenario produces too_broad health", () => { @@ -485,17 +503,12 @@ describe("Experiment 40 — Behaviour Reachability Diagnostic", () => { expect(terminalTurn.centralStatement).toContain("European market"); }); - it("Acknowledge dominance caused by combination of broad eligibility, priority order, and scenario distribution", () => { + it("Acknowledge selected whenever eligible (priority-1 rule confirmed)", () => { const reachability = global._exp40_reachability || {}; - // Acknowledge eligible 5/7 — that's the broad eligibility factor - expect(reachability.acknowledge.eligible).toBe(5); - // It is selected every time it is eligible — that's the priority order factor - expect(reachability.acknowledge.blocked).toBe(0); - // 5/7 = 71% — scenario distribution (3 scenarios, most turns have healthy health) - const totalTurns = global._exp40_audits - ? Object.values(global._exp40_audits).reduce((sum, arr) => sum + arr.length, 0) - : 7; - expect(reachability.acknowledge.eligible / totalTurns).toBeGreaterThan(0.5); + // With exclusion gate: eligible 3/7 (2 excluded by phase/progress gates) + expect(reachability.acknowledge.eligible).toBe(3); + // All 3 eligible instances are selected — priority-1 position confirmed + expect(reachability.acknowledge.selected).toBe(3); }); }); @@ -702,27 +715,27 @@ describe("Experiment 40 — Behaviour Reachability Diagnostic", () => { it("classifies each missing behaviour for the final report", () => { const reachability = global._exp40_reachability || {}; - // Summarise: eligible in real turn but blocked → eligible_but_blocked + // Summarise: eligible in 2 real turns, 1 blocked (focusing+steady ack wins), 1 selected (concluding→ack excluded) expect(reachability.summarise.eligible).toBeGreaterThan(0); expect(reachability.summarise.blocked).toBeGreaterThan(0); // Clarify: never eligible in tested scenarios, reachable only synthetically expect(reachability.clarify.eligible).toBe(0); - // Pause: eligible in real turn but blocked + // Pause: eligible in 1 real turn, now selected via the exclusion gate (stalled→ack excluded) expect(reachability.pause.eligible).toBeGreaterThan(0); - expect(reachability.pause.blocked).toBeGreaterThan(0); + expect(reachability.pause.selected).toBe(1); console.log("\n=== Experiment 40 — Final Classifications ==="); - console.log(`Summarise: eligible_but_blocked (blocked by acknowledge)`); + console.log(`Summarise: eligible_but_blocked (blocked by acknowledge in focusing phase)`); console.log(`Clarify: never_eligible_in_tested_scenarios (reachable only in synthetic case)`); - console.log(`Pause: eligible_but_blocked (blocked by acknowledge)`); + console.log(`Pause: selected via exclusion gate (stalled progress excludes ack)`); console.log(`Acknowledge: dominant because health=healthy is the most common state`); global._exp40_classifications = { summarise: "eligible_but_blocked", clarify: "never_eligible_in_tested_scenarios", - pause: "eligible_but_blocked" + pause: "selected_via_exclusion_gate" }; }); });