diff --git a/tests/wp0/v21-learning-policy-p1-decision-record.test.js b/tests/wp0/v21-learning-policy-p1-decision-record.test.js new file mode 100644 index 000000000..c22e79db6 --- /dev/null +++ b/tests/wp0/v21-learning-policy-p1-decision-record.test.js @@ -0,0 +1,94 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { createLearningPolicyDecisionContract } = require('../../backend/services/learningPolicyDecisionContract'); + +test('Learned Policy DecisionRecord binds canonical identity, persona, features, action and generation provenance', () => { + const authorityCalls = []; + const contract = createLearningPolicyDecisionContract({ + personContextAuthority: { + resolve(input) { + authorityCalls.push(input); + return { + authority: 'PersonContextAuthority', + found: true, + personId: 'person-1', + contactIds: ['contact-1'], + conversationIds: ['conversation-1'] + }; + } + } + }); + + const record = contract.createDecisionRecord({ + contactId: 'contact-1', + conversationId: 'conversation-1', + personaProfileId: 'persona-owner-v7', + featureBundle: { + relationshipStage: 'warming', + interactionBand: 'balanced', + targetLanguage: 'en' + }, + candidateStrategyBranch: 'screen_and_advance', + policyVersion: 'vw-p1-v1', + policyArtifactId: 'artifact-sha256:abc123', + generation: { + modelBrainExecutionId: 'exec-1', + candidatePlanId: 'plan-1' + } + }); + + assert.equal(authorityCalls.length, 1); + assert.equal(record.personId, 'person-1'); + assert.deepEqual(record.contactIds, ['contact-1']); + assert.deepEqual(record.conversationIds, ['conversation-1']); + assert.equal(record.personaProfileId, 'persona-owner-v7'); + assert.equal(record.candidateStrategyBranch, 'screen_and_advance'); + assert.equal(record.actionProbability, 1); + assert.equal(record.exploration, false); + assert.equal(record.policyVersion, 'vw-p1-v1'); + assert.equal(record.policyArtifactId, 'artifact-sha256:abc123'); + assert.match(record.decisionId, /^decision:/); + assert.equal(Object.isFrozen(record), true); +}); + +test('DecisionRecord fails closed on identity/persona ambiguity and rejects raw chat bodies in features', () => { + const ambiguous = createLearningPolicyDecisionContract({ + personContextAuthority: { + resolve() { + return { authority: 'PersonContextAuthority', found: true, personId: 'person-1', contactIds: ['contact-1'], conversationIds: ['conversation-2'] }; + } + } + }); + + assert.throws(() => ambiguous.createDecisionRecord({ + contactId: 'contact-1', + conversationId: 'conversation-1', + personaProfileId: 'persona-owner-v7', + featureBundle: { relationshipStage: 'warming' }, + candidateStrategyBranch: 'natural_hook', + policyVersion: 'vw-p1-v1', + policyArtifactId: 'artifact-sha256:abc123', + generation: { modelBrainExecutionId: 'exec-1' } + }), error => error?.reasonCode === 'LEARNING_POLICY_IDENTITY_BINDING_MISMATCH'); + + const valid = createLearningPolicyDecisionContract({ + personContextAuthority: { + resolve() { + return { authority: 'PersonContextAuthority', found: true, personId: 'person-1', contactIds: ['contact-1'], conversationIds: ['conversation-1'] }; + } + } + }); + assert.throws(() => valid.createDecisionRecord({ + contactId: 'contact-1', + conversationId: 'conversation-1', + personaProfileId: 'persona-owner-v7', + featureBundle: { rawChatBody: 'private text' }, + candidateStrategyBranch: 'natural_hook', + policyVersion: 'vw-p1-v1', + policyArtifactId: 'artifact-sha256:abc123', + generation: { modelBrainExecutionId: 'exec-1' } + }), error => error?.reasonCode === 'LEARNING_POLICY_FEATURE_BUNDLE_PRIVATE_BODY_FORBIDDEN'); +}); diff --git a/tests/wp0/v21-learning-policy-p1-outcome-binding.test.js b/tests/wp0/v21-learning-policy-p1-outcome-binding.test.js new file mode 100644 index 000000000..3db7b6960 --- /dev/null +++ b/tests/wp0/v21-learning-policy-p1-outcome-binding.test.js @@ -0,0 +1,62 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { createLearningOutcomeAttributionService } = require('../../backend/services/learningOutcomeAttributionService'); + +test('raw OutcomeVector is immutable, decision-bound and permanently non-trainable', () => { + const service = createLearningOutcomeAttributionService(); + const vector = service.createOutcomeVector({ + decisionId: 'decision:abc', + outcomes: [ + { outcomeId: 'outcome-1', type: 'reply_received', value: 1, evidenceRef: 'message-99' }, + { outcomeId: 'outcome-2', type: 'conversation_continued', value: 1, evidenceRef: 'conversation-1' } + ], + observedAt: '2026-08-14T11:00:00.000Z' + }); + + assert.equal(vector.signalType, 'policy_outcome_observed'); + assert.equal(vector.learningEligible, false); + assert.equal(vector.decisionId, 'decision:abc'); + assert.equal(Object.isFrozen(vector), true); + assert.equal(Object.isFrozen(vector.outcomes), true); +}); + +test('trainable binding requires the immutable eligible candidate_sent anchor and Learning-approved Langfuse Score', () => { + const service = createLearningOutcomeAttributionService(); + const vector = service.createOutcomeVector({ + decisionId: 'decision:abc', + outcomes: [{ outcomeId: 'outcome-1', type: 'reply_received', value: 1, evidenceRef: 'message-99' }], + observedAt: '2026-08-14T11:00:00.000Z' + }); + const source = { + signal_id: 'signal-1', + signal_type: 'candidate_sent', + learning_eligible: true, + signal: { decisionRecord: { decisionId: 'decision:abc' } } + }; + const score = { + authority: 'Langfuse', + approvedByLearning: true, + scoreId: 'score-1', + eligibleSourceSignalId: 'signal-1', + decisionId: 'decision:abc', + outcomeIds: ['outcome-1'], + outcomeEvidenceSetRef: 'evidence-set-1', + rewardPolicyVersion: 'reward-v1', + value: 1 + }; + + const bound = service.bindTrainableOutcome({ eligibleSourceSignal: source, outcomeVector: vector, score }); + assert.equal(bound.eligibleSourceSignalId, 'signal-1'); + assert.equal(bound.decisionId, 'decision:abc'); + assert.equal(bound.reward.authority, 'Langfuse'); + assert.equal(vector.learningEligible, false, 'binding may never upgrade the raw outcome row'); + + assert.throws(() => service.bindTrainableOutcome({ + eligibleSourceSignal: { ...source, learning_eligible: false }, + outcomeVector: vector, + score + }), error => error?.reasonCode === 'LEARNING_POLICY_ELIGIBLE_SOURCE_SIGNAL_REQUIRED'); +}); diff --git a/tests/wp0/v21-learning-policy-p1-production-consumption.test.js b/tests/wp0/v21-learning-policy-p1-production-consumption.test.js new file mode 100644 index 000000000..a629ababf --- /dev/null +++ b/tests/wp0/v21-learning-policy-p1-production-consumption.test.js @@ -0,0 +1,24 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..', '..'); +const source = () => fs.readFileSync(path.join(ROOT, 'backend/services/contextAwareReplyBrain.js'), 'utf8'); + +test('production reply brain consumes Learned Policy before existing frontier generation', () => { + const text = source(); + assert.match(text, /require\(['"]\.\/learningPolicyRuntimeAdapter['"]\)/); + assert.match(text, /selectLearnedPolicyAction\s*\(/); + assert.match(text, /candidateStrategyBranch/); + assert.match(text, /aiGateway\.execute\s*\(/, 'existing Model Brain gateway must remain the final generator'); +}); + +test('Learned Policy production consumption cannot take provider/model credential or final-text authority', () => { + const text = source(); + assert.doesNotMatch(text, /learnedPolicy[^\n]{0,120}(apiKey|credential|providerCredential)/i); + assert.doesNotMatch(text, /learnedPolicy[^\n]{0,120}modelId\s*:/i); + assert.doesNotMatch(text, /learnedPolicy[^\n]{0,120}(finalReply|finalText)\s*:/i); +}); diff --git a/tests/wp0/v21-learning-policy-p1-projection.test.js b/tests/wp0/v21-learning-policy-p1-projection.test.js new file mode 100644 index 000000000..97050511f --- /dev/null +++ b/tests/wp0/v21-learning-policy-p1-projection.test.js @@ -0,0 +1,72 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { createLearningDeepTrainingContract } = require('../../backend/services/learningDeepTrainingContract'); + +test('projectPolicy reads only immutable eligible source signals and joins raw outcomes by decisionId', async () => { + const repository = { + async listLearningSignals(query) { + assert.equal(query.learningEligible, true); + return [{ + signal_id: 'signal-1', + signal_type: 'candidate_sent', + learning_eligible: true, + scope_type: 'relationship', + scope_id: 'person-1', + signal: { + decisionRecord: { + decisionId: 'decision:abc', + candidateStrategyBranch: 'natural_hook', + featureBundle: { relationshipStage: 'warming' }, + policyVersion: 'vw-p1-v1', + policyArtifactId: 'artifact-sha256:abc123' + } + } + }]; + }, + async listPolicyOutcomeSignals({ decisionIds }) { + assert.deepEqual(decisionIds, ['decision:abc']); + return [{ + signal_id: 'raw-outcome-1', + signal_type: 'policy_outcome_observed', + learning_eligible: false, + signal: { + decisionId: 'decision:abc', + outcomes: [{ outcomeId: 'outcome-1', type: 'reply_received', value: 1, evidenceRef: 'message-99' }] + } + }]; + } + }; + const contract = createLearningDeepTrainingContract({ repository, dataPolicy: { minimize: async value => ({ allowed: true, text: value.text || '' }) } }); + const projection = await contract.projectPolicy({ + scopeType: 'relationship', + scopeId: 'person-1', + approvedScoresBySignalId: { + 'signal-1': { + authority: 'Langfuse', approvedByLearning: true, scoreId: 'score-1', name: 'policy_reward', value: 1, traceId: 'trace-1', + eligibleSourceSignalId: 'signal-1', decisionId: 'decision:abc', outcomeIds: ['outcome-1'], outcomeEvidenceSetRef: 'evidence-set-1', rewardPolicyVersion: 'reward-v1' + } + } + }); + + assert.equal(projection.authority, 'Learning'); + assert.equal(projection.readOnly, true); + assert.equal(projection.trajectory.length, 1); + assert.equal(projection.trajectory[0].decisionId, 'decision:abc'); + assert.equal(projection.trajectory[0].outcomes[0].outcomeId, 'outcome-1'); +}); + +test('projectPolicy never upgrades raw outcome eligibility', async () => { + const raw = { signal_id: 'raw-outcome-1', signal_type: 'policy_outcome_observed', learning_eligible: false, signal: { decisionId: 'decision:abc', outcomes: [] } }; + const repository = { + async listLearningSignals() { + return [{ signal_id: 'signal-1', signal_type: 'candidate_sent', learning_eligible: true, scope_type: 'relationship', scope_id: 'person-1', signal: { decisionRecord: { decisionId: 'decision:abc', candidateStrategyBranch: 'natural_hook', featureBundle: {}, policyVersion: 'vw-p1-v1', policyArtifactId: 'artifact-sha256:abc123' } } }]; + }, + async listPolicyOutcomeSignals() { return [raw]; } + }; + const contract = createLearningDeepTrainingContract({ repository, dataPolicy: { minimize: async () => ({ allowed: true, text: '' }) } }); + await contract.projectPolicy({ scopeType: 'relationship', scopeId: 'person-1', approvedScoresBySignalId: { 'signal-1': { authority: 'Langfuse', approvedByLearning: true, scoreId: 'score-1', name: 'policy_reward', value: 1, traceId: 'trace-1', eligibleSourceSignalId: 'signal-1', decisionId: 'decision:abc', outcomeIds: [], outcomeEvidenceSetRef: 'evidence-set-1', rewardPolicyVersion: 'reward-v1' } } }); + assert.equal(raw.learning_eligible, false); +}); diff --git a/tests/wp0/v21-learning-policy-p1-supply-chain.test.js b/tests/wp0/v21-learning-policy-p1-supply-chain.test.js new file mode 100644 index 000000000..3360b8dad --- /dev/null +++ b/tests/wp0/v21-learning-policy-p1-supply-chain.test.js @@ -0,0 +1,25 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..', '..'); +const read = p => fs.readFileSync(path.join(ROOT, p), 'utf8'); + +test('Vowpal Wabbit supply chain is pinned to 9.11.2 with exact upstream provenance and BSD-3-Clause notice', () => { + const pyproject = read('runtime/learning-growth/python/pyproject.toml'); + const lock = read('runtime/learning-growth/python/uv.lock'); + const upstream = JSON.parse(read('config/upstreams/v21-learning-growth-brain-p0.json')); + const notices = read('THIRD_PARTY_NOTICES.md'); + const license = read('third_party/licenses/vowpal-wabbit-BSD-3-Clause.txt'); + + assert.match(pyproject, /vowpalwabbit\s*==\s*9\.11\.2/i); + assert.match(lock, /name\s*=\s*["']vowpalwabbit["']/i); + assert.match(lock, /version\s*=\s*["']9\.11\.2["']/i); + assert.match(JSON.stringify(upstream), /122bae254a5b8bc2b774d13b33d53e6dbc2cfba7/); + assert.match(notices, /Vowpal Wabbit/i); + assert.match(notices, /BSD-3-Clause/i); + assert.match(license, /Redistribution and use in source and binary forms/i); +}); diff --git a/tests/wp0/v21-learning-policy-p1-vw-runtime.test.js b/tests/wp0/v21-learning-policy-p1-vw-runtime.test.js new file mode 100644 index 000000000..d0cfb13fd --- /dev/null +++ b/tests/wp0/v21-learning-policy-p1-vw-runtime.test.js @@ -0,0 +1,40 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..', '..'); +const read = p => fs.readFileSync(path.join(ROOT, p), 'utf8'); + +const { createLearningPolicyRuntimeAdapter } = require('../../backend/services/learningPolicyRuntimeAdapter'); + +test('Learning runtime adapter delegates the action head to sealed Vowpal Wabbit and keeps P1 deterministic', async () => { + const calls = []; + const adapter = createLearningPolicyRuntimeAdapter({ + invokeVowpalWabbit: async input => { + calls.push(input); + return { action: 'playful_attraction', policyVersion: 'vw-p1-v1', policyArtifactId: 'artifact-sha256:abc123' }; + } + }); + const decision = await adapter.selectLearnedPolicyAction({ + featureBundle: { relationshipStage: 'warming', interactionBand: 'balanced' }, + allowedActions: ['natural_hook', 'playful_attraction', 'direct_advance', 'screen_and_advance', 'leave_aftertaste'] + }); + + assert.equal(calls.length, 1); + assert.equal(decision.candidateStrategyBranch, 'playful_attraction'); + assert.equal(decision.actionProbability, 1); + assert.equal(decision.exploration, false); + assert.equal(decision.providerRoutingAuthority, undefined); + assert.equal(decision.finalReply, undefined); +}); + +test('sealed Learning Python entrypoint exposes the VW policy action mode without provider credentials or local text generation', () => { + const source = read('runtime/learning-growth/python/learning_entrypoint.py'); + assert.match(source, /vowpalwabbit/i); + assert.match(source, /learned[_-]policy/i); + assert.doesNotMatch(source, /(OPENAI_API_KEY|ANTHROPIC_API_KEY|provider[_-]?credential)/i); + assert.doesNotMatch(source, /(generate[_-]?reply|final[_-]?reply)/i); +});